Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled

Includes 30 custom nodes committed directly, 7 Civitai-exclusive
loras stored via Git LFS, and a setup script that installs all
dependencies and downloads HuggingFace-hosted models on vast.ai.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 00:55:26 +00:00
parent 2b70ab9ad0
commit f09734b0ee
2274 changed files with 748556 additions and 3 deletions

View File

@@ -0,0 +1,103 @@
import type {
ComfyApp,
INodeInputSlot,
INodeOutputSlot,
LGraphNode,
LLink,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {IoDirection, addConnectionLayoutSupport, followConnectionUntilType} from "./utils.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {debounce} from "rgthree/common/shared_utils.js";
class RgthreeAnySwitch extends RgthreeBaseServerNode {
static override title = NodeTypesString.ANY_SWITCH;
static override type = NodeTypesString.ANY_SWITCH;
static comfyClass = NodeTypesString.ANY_SWITCH;
private stabilizeBound = this.stabilize.bind(this);
private nodeType: string | string[] | null = null;
constructor(title = RgthreeAnySwitch.title) {
super(title);
// Adding five. Note, configure will add as many as was in the stored workflow automatically.
this.addAnyInput(5);
}
override onConnectionsChange(
type: number,
slotIndex: number,
isConnected: boolean,
linkInfo: LLink,
ioSlot: INodeOutputSlot | INodeInputSlot,
) {
super.onConnectionsChange?.(type, slotIndex, isConnected, linkInfo, ioSlot);
this.scheduleStabilize();
}
onConnectionsChainChange() {
this.scheduleStabilize();
}
scheduleStabilize(ms = 64) {
return debounce(this.stabilizeBound, ms);
}
private addAnyInput(num = 1) {
for (let i = 0; i < num; i++) {
this.addInput(
`any_${String(this.inputs.length + 1).padStart(2, "0")}`,
(this.nodeType || "*") as string,
);
}
}
stabilize() {
// First, clean up the dynamic number of inputs.
removeUnusedInputsFromEnd(this, 4);
this.addAnyInput();
// We prefer the inputs, then the output.
let connectedType = followConnectionUntilType(this, IoDirection.INPUT, undefined, true);
if (!connectedType) {
connectedType = followConnectionUntilType(this, IoDirection.OUTPUT, undefined, true);
}
// TODO: What this doesn't do is broadcast to other nodes when its type changes. Reroute node
// does, but, for now, if this was connected to another Any Switch, say, the second one wouldn't
// change its type when the first does. The user would need to change the connections.
this.nodeType = connectedType?.type || "*";
for (const input of this.inputs) {
input.type = this.nodeType as string; // So, types can indeed be arrays,,
}
for (const output of this.outputs) {
output.type = this.nodeType as string; // So, types can indeed be arrays,,
output.label =
output.type === "RGTHREE_CONTEXT"
? "CONTEXT"
: Array.isArray(this.nodeType) || this.nodeType.includes(",")
? connectedType?.label || String(this.nodeType)
: String(this.nodeType);
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeAnySwitch);
addConnectionLayoutSupport(RgthreeAnySwitch, app, [
["Left", "Right"],
["Right", "Left"],
]);
}
}
app.registerExtension({
name: "rgthree.AnySwitch",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: any, app: ComfyApp) {
if (nodeData.name === "Any Switch (rgthree)") {
RgthreeAnySwitch.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,354 @@
import type {
Vector2,
LLink,
INodeInputSlot,
INodeOutputSlot,
LGraphNode as TLGraphNode,
ISlotType,
ConnectByTypeOptions,
TWidgetType,
IWidgetOptions,
IWidget,
IBaseWidget,
WidgetTypeMap,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {rgthree} from "./rgthree.js";
import {
PassThroughFollowing,
addConnectionLayoutSupport,
addMenuItem,
getConnectedInputNodes,
getConnectedInputNodesAndFilterPassThroughs,
getConnectedOutputNodes,
getConnectedOutputNodesAndFilterPassThroughs,
} from "./utils.js";
/**
* A Virtual Node that allows any node's output to connect to it.
*/
export class BaseAnyInputConnectedNode extends RgthreeBaseVirtualNode {
override isVirtualNode = true;
/**
* Whether inputs show the immediate nodes, or follow and show connected nodes through
* passthrough nodes.
*/
readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.NONE;
debouncerTempWidth: number = 0;
schedulePromise: Promise<void> | null = null;
constructor(title = BaseAnyInputConnectedNode.title) {
super(title);
}
override onConstructed() {
this.addInput("", "*");
return super.onConstructed();
}
override clone() {
const cloned = super.clone()!;
// Copying to clipboard (and also, creating node templates) work by cloning nodes and, for some
// reason, it manually manipulates the cloned data. So, we want to keep the present input slots
// so if it's pasted/templatized the data is correct. Otherwise, clear the inputs and so the new
// node is ready to go, fresh.
if (!rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes) {
while (cloned.inputs.length > 1) {
cloned.removeInput(cloned.inputs.length - 1);
}
if (cloned.inputs[0]) {
cloned.inputs[0].label = "";
}
}
return cloned;
}
/**
* Schedules a promise to run a stabilization, debouncing duplicate requests.
*/
scheduleStabilizeWidgets(ms = 100) {
if (!this.schedulePromise) {
this.schedulePromise = new Promise((resolve) => {
setTimeout(() => {
this.schedulePromise = null;
this.doStablization();
resolve();
}, ms);
});
}
return this.schedulePromise;
}
/**
* Ensures we have at least one empty input at the end, returns true if changes were made, or false
* if no changes were needed.
*/
private stabilizeInputsOutputs(): boolean {
let changed = false;
const hasEmptyInput = !this.inputs[this.inputs.length - 1]?.link;
if (!hasEmptyInput) {
this.addInput("", "*");
changed = true;
}
for (let index = this.inputs.length - 2; index >= 0; index--) {
const input = this.inputs[index]!;
if (!input.link) {
this.removeInput(index);
changed = true;
} else {
const node = getConnectedInputNodesAndFilterPassThroughs(
this,
this,
index,
this.inputsPassThroughFollowing,
)[0];
const newName = node?.title || "";
if (input.name !== newName) {
input.name = node?.title || "";
changed = true;
}
}
}
return changed;
}
/**
* Stabilizes the node's inputs and widgets.
*/
private doStablization() {
if (!this.graph) {
return;
}
let dirty = false;
// When we add/remove widgets, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
(this as any)._tempWidth = this.size[0];
dirty = this.stabilizeInputsOutputs();
const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
dirty = this.handleLinkedNodesStabilization(linkedNodes) || dirty;
// Only mark dirty if something's changed.
if (dirty) {
this.graph.setDirtyCanvas(true, true);
}
// Schedule another stabilization in the future.
this.scheduleStabilizeWidgets(500);
}
/**
* Handles stabilization of linked nodes. To be overridden. Should return true if changes were
* made, or false if no changes were needed.
*/
handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]): boolean {
linkedNodes; // No-op, but makes overridding in VSCode cleaner.
throw new Error("handleLinkedNodesStabilization should be overridden.");
}
onConnectionsChainChange() {
this.scheduleStabilizeWidgets();
}
override onConnectionsChange(
type: number,
index: number,
connected: boolean,
linkInfo: LLink,
ioSlot: INodeOutputSlot | INodeInputSlot,
) {
super.onConnectionsChange &&
super.onConnectionsChange(type, index, connected, linkInfo, ioSlot);
if (!linkInfo) return;
// Follow outputs to see if we need to trigger an onConnectionChange.
const connectedNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
for (const node of connectedNodes) {
if ((node as BaseAnyInputConnectedNode).onConnectionsChainChange) {
(node as BaseAnyInputConnectedNode).onConnectionsChainChange();
}
}
this.scheduleStabilizeWidgets();
}
override removeInput(slot: number) {
(this as any)._tempWidth = this.size[0];
return super.removeInput(slot);
}
override addInput<TProperties extends Partial<INodeInputSlot>>(
name: string,
type: ISlotType,
extra_info?: TProperties | undefined,
): INodeInputSlot & TProperties {
(this as any)._tempWidth = this.size[0];
return super.addInput(name, type, extra_info);
}
override addWidget<Type extends TWidgetType, TValue extends WidgetTypeMap[Type]["value"]>(
type: Type,
name: string,
value: TValue,
callback: IBaseWidget["callback"] | string | null,
options?: IWidgetOptions | string,
):
| IBaseWidget<string | number | boolean | object | undefined, string, IWidgetOptions<unknown>>
| WidgetTypeMap[Type] {
(this as any)._tempWidth = this.size[0];
return super.addWidget(type, name, value, callback, options);
}
override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
(this as any)._tempWidth = this.size[0];
super.removeWidget(widget);
}
override computeSize(out: Vector2) {
let size = super.computeSize(out);
if ((this as any)._tempWidth) {
size[0] = (this as any)._tempWidth;
// We sometimes get repeated calls to compute size, so debounce before clearing.
this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
this.debouncerTempWidth = setTimeout(() => {
(this as any)._tempWidth = null;
}, 32);
}
// If we're collapsed, then subtract the total calculated height of the other input slots.
if (this.properties["collapse_connections"]) {
const rows = Math.max(this.inputs?.length || 0, this.outputs?.length || 0, 1) - 1;
size[1] = size[1] - rows * LiteGraph.NODE_SLOT_HEIGHT;
}
setTimeout(() => {
this.graph?.setDirtyCanvas(true, true);
}, 16);
return size;
}
/**
* When we connect our output, check our inputs and make sure we're not trying to connect a loop.
*/
override onConnectOutput(
outputIndex: number,
inputType: string | -1,
inputSlot: INodeInputSlot,
inputNode: TLGraphNode,
inputIndex: number,
): boolean {
let canConnect = true;
if (super.onConnectOutput) {
canConnect = super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
}
if (canConnect) {
const nodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
if (nodes.includes(inputNode)) {
alert(
`Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
`a situation that could create a time paradox, the results of which could cause a ` +
`chain reaction that would unravel the very fabric of the space time continuum, ` +
`and destroy the entire universe!`,
);
canConnect = false;
}
}
return canConnect;
}
override onConnectInput(
inputIndex: number,
outputType: string | -1,
outputSlot: INodeOutputSlot,
outputNode: TLGraphNode,
outputIndex: number,
): boolean {
let canConnect = true;
if (super.onConnectInput) {
canConnect = super.onConnectInput(
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
);
}
if (canConnect) {
const nodes = getConnectedOutputNodes(this); // We want passthrough nodes, since they will loop.
if (nodes.includes(outputNode)) {
alert(
`Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
`a situation that could create a time paradox, the results of which could cause a ` +
`chain reaction that would unravel the very fabric of the space time continuum, ` +
`and destroy the entire universe!`,
);
canConnect = false;
}
}
return canConnect;
}
/**
* If something is dropped on us, just add it to the bottom. onConnectInput should already cancel
* if it's disallowed.
*/
override connectByTypeOutput(
slot: number | string,
sourceNode: TLGraphNode,
sourceSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
const lastInput = this.inputs[this.inputs.length - 1];
if (!lastInput?.link && lastInput?.type === "*") {
var sourceSlot = sourceNode.findOutputSlotByType(sourceSlotType, false, true);
return sourceNode.connect(sourceSlot, this, slot);
}
return super.connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn);
}
static override setUp() {
super.setUp();
addConnectionLayoutSupport(this, app, [
["Left", "Right"],
["Right", "Left"],
]);
addMenuItem(this, app, {
name: (node) =>
`${node.properties?.["collapse_connections"] ? "Show" : "Collapse"} Connections`,
property: "collapse_connections",
prepareValue: (_value, node) => !node.properties?.["collapse_connections"],
callback: (_node) => {
app.canvas.getCurrentGraph()?.setDirtyCanvas(true, true);
},
});
}
}
// Ok, hack time! LGraphNode's connectByType is powerful, but for our nodes, that have multiple "*"
// input types, it seems it just takes the first one, and disconnects it. I'd rather we don't do
// that and instead take the next free one. If that doesn't work, then we'll give it to the old
// method.
const oldLGraphNodeConnectByType = LGraphNode.prototype.connectByType;
LGraphNode.prototype.connectByType = function connectByType(
slot: number | string,
targetNode: TLGraphNode,
targetSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
// If we're dropping on a node, and the last input is free and an "*" type, then connect there
// first...
if (targetNode.inputs) {
for (const [index, input] of targetNode.inputs.entries()) {
if (!input.link && input.type === "*") {
this.connect(slot, targetNode, index);
return null;
}
}
}
return (
(oldLGraphNodeConnectByType &&
oldLGraphNodeConnectByType.call(this, slot, targetNode, targetSlotType, optsIn)) ||
null
);
};

View File

@@ -0,0 +1,504 @@
import type {
IWidget,
LGraphCanvas,
IContextMenuValue,
IFoundSlot,
LGraphEventMode,
LGraphNodeConstructor,
ISerialisedNode,
IBaseWidget,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import type {RgthreeBaseServerNodeConstructor} from "typings/rgthree.js";
import {app} from "scripts/app.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {LogLevel, rgthree} from "./rgthree.js";
import {addHelpMenuItem} from "./utils.js";
import {RgthreeHelpDialog} from "rgthree/common/dialog.js";
import {
importIndividualNodesInnerOnDragDrop,
importIndividualNodesInnerOnDragOver,
} from "./feature_import_individual_nodes.js";
import {defineProperty, moveArrayItem} from "rgthree/common/shared_utils.js";
/**
* A base node with standard methods, directly extending the LGraphNode.
* This can be used for ui-nodes and a further base for server nodes.
*/
export abstract class RgthreeBaseNode extends LGraphNode {
/**
* Action strings that can be exposed and triggered from other nodes, like Fast Actions Button.
*/
static exposedActions: string[] = [];
static override title: string = "__NEED_CLASS_TITLE__";
static override type: string = "__NEED_CLASS_TYPE__";
static override category = "rgthree";
static _category = "rgthree"; // `category` seems to get reset by comfy, so reset to this after.
/** Our constructor ensures there's a widget array, so we get rid of the nullability. */
override widgets!: IWidget[];
/**
* The comfyClass is property ComfyUI and extensions may care about, even through it is only for
* server nodes. RgthreeBaseServerNode below overrides this with the expected value and we just
* set it here so extensions that are none the wiser don't break on some unchecked string method
* call on an undefined calue.
*/
override comfyClass: string = "__NEED_COMFY_CLASS__";
/** Used by the ComfyUI-Manager badge. */
readonly nickname = "rgthree";
/** Are we a virtual node? */
override readonly isVirtualNode: boolean = false;
/** Are we able to be dropped on (if config is enabled too). */
isDropEnabled = false;
/** A state member determining if we're currently removed. */
removed = false;
/** A state member determining if we're currently "configuring."" */
configuring = false;
/** A temporary width value that can be used to ensure compute size operates correctly. */
_tempWidth = 0;
/** Private Mode member so we can override the setter/getter and call an `onModeChange`. */
private rgthree_mode?: LGraphEventMode;
/** An internal bool set when `onConstructed` is run. */
private __constructed__ = false;
/** The help dialog. */
private helpDialog: RgthreeHelpDialog | null = null;
constructor(title = RgthreeBaseNode.title, skipOnConstructedCall = true) {
super(title);
if (title == "__NEED_CLASS_TITLE__") {
throw new Error("RgthreeBaseNode needs overrides.");
}
// Ensure these exist since some other extensions will break in their onNodeCreated.
this.widgets = this.widgets || [];
this.properties = this.properties || {};
// Some checks we want to do after we're constructed, looking that data is set correctly and
// that our base's `onConstructed` was called (if not, set a DEV warning).
setTimeout(() => {
// Check we have a comfyClass defined.
if (this.comfyClass == "__NEED_COMFY_CLASS__") {
throw new Error("RgthreeBaseNode needs a comfy class override.");
}
if (this.constructor.type == "__NEED_CLASS_TYPE__") {
throw new Error("RgthreeBaseNode needs overrides.");
}
// Ensure we've called onConstructed before we got here.
this.checkAndRunOnConstructed();
});
defineProperty(this, "mode", {
get: () => {
return this.rgthree_mode;
},
set: (mode: LGraphEventMode) => {
if (this.rgthree_mode != mode) {
const oldMode = this.rgthree_mode;
this.rgthree_mode = mode;
this.onModeChange(oldMode, mode);
}
},
});
}
private checkAndRunOnConstructed() {
if (!this.__constructed__) {
this.onConstructed();
const [n, v] = rgthree.logger.logParts(
LogLevel.DEV,
`[RgthreeBaseNode] Child class did not call onConstructed for "${this.type}.`,
);
console[n]?.(...v);
}
return this.__constructed__;
}
override onDragOver(e: DragEvent): boolean {
if (!this.isDropEnabled) return false;
return importIndividualNodesInnerOnDragOver(this, e);
}
override async onDragDrop(e: DragEvent): Promise<boolean> {
if (!this.isDropEnabled) return false;
return importIndividualNodesInnerOnDragDrop(this, e);
}
/**
* When a node is finished with construction, we must call this. Failure to do so will result in
* an error message from the timeout in this base class. This is broken out and becomes the
* responsibility of the child class because
*/
onConstructed() {
if (this.__constructed__) return false;
// This is kinda a hack, but if this.type is still null, then set it to undefined to match.
this.type = this.type ?? undefined;
this.__constructed__ = true;
rgthree.invokeExtensionsAsync("nodeCreated", this);
return this.__constructed__;
}
override configure(info: ISerialisedNode): void {
this.configuring = true;
super.configure(info);
// Fix https://github.com/comfyanonymous/ComfyUI/issues/1448 locally.
// Can removed when fixed and adopted.
for (const w of this.widgets || []) {
w.last_y = w.last_y || 0;
}
this.configuring = false;
}
/**
* Override clone for, at the least, deep-copying properties.
*/
override clone() {
const cloned = super.clone()!;
// This is wild, but LiteGraph doesn't deep clone data, so we will. We'll use structured clone,
// which most browsers in 2022 support, but but we'll check.
if (cloned?.properties && !!window.structuredClone) {
cloned.properties = structuredClone(cloned.properties);
}
// [🤮] https://github.com/Comfy-Org/ComfyUI_frontend/issues/5037
// ComfyUI started throwing errors when some of our nodes wanted to remove inputs when cloning
// (like our dynamic inputs) because the disconnect method that's automatically called assumes
// there should be a graph. For now, I _think_ we can simply assign the current graph to avoid
// the error, which would then be overwritten when placed...
cloned.graph = this.graph;
return cloned;
}
/** When a mode change, we want all connected nodes to match. */
onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
// Override
}
/**
* Given a string, do something. At the least, handle any `exposedActions` that may be called and
* passed into from other nodes, like Fast Actions Button
*/
async handleAction(action: string) {
action; // No-op. Should be overridden but OK if not.
}
/**
* This didn't exist in LiteGraph/Comfy, but now it's added. Ours was a bit more flexible, though.
*/
override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
if (typeof widget === "number") {
widget = this.widgets[widget];
}
if (!widget) return;
// Comfy added their own removeWidget, but it's not fully rolled out to stable, so keep our
// original implementation.
// TODO: Actually, scratch that. The ComfyUI impl doesn't call widtget.onRemove?.() and so
// we shouldn't switch to it yet. See: https://github.com/Comfy-Org/ComfyUI_frontend/issues/5090
const canUseComfyUIRemoveWidget = false;
if (canUseComfyUIRemoveWidget && typeof super.removeWidget === 'function') {
super.removeWidget(widget as IBaseWidget);
} else {
const index = this.widgets.indexOf(widget as IWidget);
if (index > -1) {
this.widgets.splice(index, 1);
}
widget.onRemove?.();
}
}
/**
* Replaces an existing widget.
*/
replaceWidget(widgetOrSlot: IWidget | number | undefined, newWidget: IWidget) {
let index = null;
if (widgetOrSlot) {
index = typeof widgetOrSlot === "number" ? widgetOrSlot : this.widgets.indexOf(widgetOrSlot);
this.removeWidget(this.widgets[index]!);
}
index = index != null ? index : this.widgets.length - 1;
if (this.widgets.includes(newWidget)) {
moveArrayItem(this.widgets, newWidget, index);
} else {
this.widgets.splice(index, 0, newWidget);
}
}
/**
* A default version of the logive when a node does not set `getSlotMenuOptions`. This is
* necessary because child nodes may want to define getSlotMenuOptions but LiteGraph then won't do
* it's default logic. This bakes it so child nodes can call this instead (and this doesn't set
* getSlotMenuOptions for all child nodes in case it doesn't exist).
*/
defaultGetSlotMenuOptions(slot: IFoundSlot): IContextMenuValue[] {
const menu_info: IContextMenuValue[] = [];
if (slot?.output?.links?.length) {
menu_info.push({content: "Disconnect Links", slot});
}
let inputOrOutput = slot.input || slot.output;
if (inputOrOutput) {
if (inputOrOutput.removable) {
menu_info.push(
inputOrOutput.locked ? {content: "Cannot remove"} : {content: "Remove Slot", slot},
);
}
if (!inputOrOutput.nameLocked) {
menu_info.push({content: "Rename Slot", slot});
}
}
return menu_info;
}
override onRemoved(): void {
super.onRemoved?.();
this.removed = true;
}
static setUp<T extends RgthreeBaseNode>(...args: any[]) {
// No-op.
}
/**
* A function to provide help text to be overridden.
*/
getHelp() {
return "";
}
showHelp() {
const help = this.getHelp() || (this.constructor as any).help;
if (help) {
this.helpDialog = new RgthreeHelpDialog(this, help).show();
this.helpDialog.addEventListener("close", (e) => {
this.helpDialog = null;
});
}
}
override onKeyDown(event: KeyboardEvent): void {
KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
if (event.key == "?" && !this.helpDialog) {
this.showHelp();
}
}
override onKeyUp(event: KeyboardEvent): void {
KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
}
override getExtraMenuOptions(
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] {
// Some other extensions override getExtraMenuOptions on the nodeType as it comes through from
// the server, so we can call out to that if we don't have our own.
if (super.getExtraMenuOptions) {
super.getExtraMenuOptions?.apply(this, [canvas, options]);
} else if (this.constructor.nodeType?.prototype?.getExtraMenuOptions) {
this.constructor.nodeType?.prototype?.getExtraMenuOptions?.apply(this, [canvas, options]);
}
// If we have help content, then add a menu item.
const help = this.getHelp() || (this.constructor as any).help;
if (help) {
addHelpMenuItem(this, help, options);
}
return [];
}
}
/**
* A virtual node. Right now, this is just a wrapper for RgthreeBaseNode (which was the initial
* base virtual node).
*/
export class RgthreeBaseVirtualNode extends RgthreeBaseNode {
override isVirtualNode = true;
constructor(title = RgthreeBaseNode.title) {
super(title, false);
}
static override setUp() {
if (!this.type) {
throw new Error(`Missing type for RgthreeBaseVirtualNode: ${this.title}`);
}
LiteGraph.registerNodeType(this.type, this);
if (this._category) {
this.category = this._category;
}
}
}
/**
* A base node with standard methods, extending the LGraphNode.
* This is somewhat experimental, but if comfyui is going to keep breaking widgets and inputs, it
* seems safer than NOT overriding.
*/
export class RgthreeBaseServerNode extends RgthreeBaseNode {
static nodeType: LGraphNodeConstructor | null = null;
static nodeData: ComfyNodeDef | null = null;
// Drop is enabled by default for server nodes.
override isDropEnabled = true;
constructor(title: string) {
super(title, true);
this.serialize_widgets = true;
this.setupFromServerNodeData();
this.onConstructed();
}
getWidgets() {
return ComfyWidgets;
}
/**
* This takes the server data and builds out the inputs, outputs and widgets. It's similar to the
* ComfyNode constructor in registerNodes in ComfyUI's app.js, but is more stable and thus
* shouldn't break as often when it modifyies widgets and types.
*/
async setupFromServerNodeData() {
const nodeData = (this.constructor as any).nodeData;
if (!nodeData) {
throw Error("No node data");
}
// Necessary for serialization so Comfy backend can check types.
// Serialized as `class_type`. See app.js#graphToPrompt
this.comfyClass = nodeData.name;
let inputs = nodeData["input"]["required"];
if (nodeData["input"]["optional"] != undefined) {
inputs = Object.assign({}, inputs, nodeData["input"]["optional"]);
}
const WIDGETS = this.getWidgets();
const config: {minWidth: number; minHeight: number; widget?: null | {options: any}} = {
minWidth: 1,
minHeight: 1,
widget: null,
};
for (const inputName in inputs) {
const inputData = inputs[inputName];
const type = inputData[0];
// If we're forcing the input, just do it now and forget all that widget stuff.
// This is one of the differences from ComfyNode and provides smoother experience for inputs
// that are going to remain inputs anyway.
// Also, it fixes https://github.com/comfyanonymous/ComfyUI/issues/1404 (for rgthree nodes)
if (inputData[1]?.forceInput) {
this.addInput(inputName, type);
} else {
let widgetCreated = true;
if (Array.isArray(type)) {
// Enums
Object.assign(config, WIDGETS.COMBO(this, inputName, inputData, app) || {});
} else if (`${type}:${inputName}` in WIDGETS) {
// Support custom widgets by Type:Name
Object.assign(
config,
WIDGETS[`${type}:${inputName}`]!(this, inputName, inputData, app) || {},
);
} else if (type in WIDGETS) {
// Standard type widgets
Object.assign(config, WIDGETS[type]!(this, inputName, inputData, app) || {});
} else {
// Node connection inputs
this.addInput(inputName, type);
widgetCreated = false;
}
// Don't actually need this right now, but ported it over from ComfyWidget.
if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
if (!config.widget.options) config.widget.options = {};
config.widget.options.forceInput = inputData[1].forceInput;
}
if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
if (!config.widget.options) config.widget.options = {};
config.widget.options.defaultInput = inputData[1].defaultInput;
}
}
}
for (const o in nodeData["output"]) {
let output = nodeData["output"][o];
if (output instanceof Array) output = "COMBO";
const outputName = nodeData["output_name"][o] || output;
const outputShape = nodeData["output_is_list"][o]
? LiteGraph.GRID_SHAPE
: LiteGraph.CIRCLE_SHAPE;
this.addOutput(outputName, output, {shape: outputShape});
}
const s = this.computeSize();
// Sometime around v1.12.6 this broke as `minWidth` and `minHeight` were being explicitly set
// to `undefined` in the above Object.assign call (specifically for `WIDGETS[INT]`. We can avoid
// that by ensureing we're at a number in that case.
// See https://github.com/Comfy-Org/ComfyUI_frontend/issues/3045
s[0] = Math.max(config.minWidth ?? 1, s[0] * 1.5);
s[1] = Math.max(config.minHeight ?? 1, s[1]);
this.size = s;
this.serialize_widgets = true;
}
static __registeredForOverride__: boolean = false;
static registerForOverride(
comfyClass: typeof LGraphNode,
nodeData: ComfyNodeDef,
rgthreeClass: RgthreeBaseServerNodeConstructor,
) {
if (OVERRIDDEN_SERVER_NODES.has(comfyClass)) {
throw Error(
`Already have a class to override ${
comfyClass.type || comfyClass.name || comfyClass.title
}`,
);
}
OVERRIDDEN_SERVER_NODES.set(comfyClass, rgthreeClass);
// Mark the rgthreeClass as `__registeredForOverride__` because ComfyUI will repeatedly call
// this and certain setups will only want to setup once (like adding context menus, etc).
if (!rgthreeClass.__registeredForOverride__) {
rgthreeClass.__registeredForOverride__ = true;
rgthreeClass.nodeType = comfyClass;
rgthreeClass.nodeData = nodeData;
rgthreeClass.onRegisteredForOverride(comfyClass, rgthreeClass);
}
}
static onRegisteredForOverride(comfyClass: any, rgthreeClass: any) {
// To be overridden
}
}
/**
* Keeps track of the rgthree-comfy nodes that come from the server (and want to be ComfyNodes) that
* we override into a own, more flexible and cleaner nodes.
*/
const OVERRIDDEN_SERVER_NODES = new Map<any, any>();
const oldregisterNodeType = LiteGraph.registerNodeType;
/**
* ComfyUI calls registerNodeType with its ComfyNode, but we don't trust that will remain stable, so
* we need to identify it, intercept it, and supply our own class for the node.
*/
LiteGraph.registerNodeType = async function (nodeId: string, baseClass: any) {
const clazz = OVERRIDDEN_SERVER_NODES.get(baseClass) || baseClass;
if (clazz !== baseClass) {
const classLabel = clazz.type || clazz.name || clazz.title;
const [n, v] = rgthree.logger.logParts(
LogLevel.DEBUG,
`${nodeId}: replacing default ComfyNode implementation with custom ${classLabel} class.`,
);
console[n]?.(...v);
// Note, we don't currently call our rgthree.invokeExtensionsAsync w/ beforeRegisterNodeDef as
// this runs right after that. However, this does mean that extensions cannot actually change
// anything about overriden server rgthree nodes in their beforeRegisterNodeDef (as when comfy
// calls it, it's for the wrong ComfyNode class). Calling it here, however, would re-run
// everything causing more issues than not. If we wanted to support beforeRegisterNodeDef then
// it would mean rewriting ComfyUI's registerNodeDef which, frankly, is not worth it.
}
return oldregisterNodeType.call(LiteGraph, nodeId, clazz);
};

View File

@@ -0,0 +1,99 @@
import type {INodeOutputSlot, LGraphNode} from "@comfyorg/frontend";
import {rgthree} from "./rgthree.js";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {
PassThroughFollowing,
getConnectedInputNodes,
getConnectedInputNodesAndFilterPassThroughs,
shouldPassThrough,
} from "./utils.js";
/**
* Base collector node that monitors changing inputs and outputs.
*/
export class BaseCollectorNode extends BaseAnyInputConnectedNode {
/**
* We only want to show nodes through re_route nodes, other pass through nodes show each input.
*/
override readonly inputsPassThroughFollowing: PassThroughFollowing =
PassThroughFollowing.REROUTE_ONLY;
readonly logger = rgthree.newLogSession("[BaseCollectorNode]");
constructor(title?: string) {
super(title);
}
override clone() {
const cloned = super.clone()!;
return cloned;
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
return false; // No-op, no widgets.
}
/**
* When we connect an input, check to see if it's already connected and cancel it.
*/
override onConnectInput(
inputIndex: number,
outputType: string | -1,
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
): boolean {
let canConnect = super.onConnectInput(
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
);
if (canConnect) {
const allConnectedNodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
const nodesAlreadyInSlot = getConnectedInputNodes(this, undefined, inputIndex);
if (allConnectedNodes.includes(outputNode)) {
// If we're connecting to the same slot, then allow it by replacing the one we have.
// const slotsOriginNode = getOriginNodeByLink(this.inputs[inputIndex]?.link);
const [n, v] = this.logger.debugParts(
`${outputNode.title} is already connected to ${this.title}.`,
);
console[n]?.(...v);
if (nodesAlreadyInSlot.includes(outputNode)) {
const [n, v] = this.logger.debugParts(
`... but letting it slide since it's for the same slot.`,
);
console[n]?.(...v);
} else {
canConnect = false;
}
}
if (canConnect && shouldPassThrough(outputNode, PassThroughFollowing.REROUTE_ONLY)) {
const connectedNode = getConnectedInputNodesAndFilterPassThroughs(
outputNode,
undefined,
undefined,
PassThroughFollowing.REROUTE_ONLY,
)[0];
if (connectedNode && allConnectedNodes.includes(connectedNode)) {
// If we're connecting to the same slot, then allow it by replacing the one we have.
const [n, v] = this.logger.debugParts(
`${connectedNode.title} is already connected to ${this.title}.`,
);
console[n]?.(...v);
if (nodesAlreadyInSlot.includes(connectedNode)) {
const [n, v] = this.logger.debugParts(
`... but letting it slide since it's for the same slot.`,
);
console[n]?.(...v);
} else {
canConnect = false;
}
}
}
}
return canConnect;
}
}

View File

@@ -0,0 +1,106 @@
import type {LGraphNode, IWidget} from "@comfyorg/frontend";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {changeModeOfNodes, PassThroughFollowing} from "./utils.js";
import {wait} from "rgthree/common/shared_utils.js";
export class BaseNodeModeChanger extends BaseAnyInputConnectedNode {
override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
static collapsible = false;
override isVirtualNode = true;
// These Must be overriden
readonly modeOn: number = -1;
readonly modeOff: number = -1;
static "@toggleRestriction" = {
type: "combo",
values: ["default", "max one", "always one"],
};
constructor(title?: string) {
super(title);
this.properties["toggleRestriction"] = "default";
}
override onConstructed(): boolean {
wait(10).then(() => {
if (this.modeOn < 0 || this.modeOff < 0) {
throw new Error("modeOn and modeOff must be overridden.");
}
});
this.addOutput("OPT_CONNECTION", "*");
return super.onConstructed();
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
let changed = false;
for (const [index, node] of linkedNodes.entries()) {
let widget: IWidget | undefined = this.widgets && this.widgets[index];
if (!widget) {
// When we add a widget, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
(this as any)._tempWidth = this.size[0];
widget = this.addWidget("toggle", "", false, "", {on: "yes", off: "no"}) as IWidget;
changed = true;
}
if (node) {
changed = this.setWidget(widget, node) || changed;
}
}
if (this.widgets && this.widgets.length > linkedNodes.length) {
this.widgets.length = linkedNodes.length;
changed = true;
}
return changed;
}
private setWidget(widget: IWidget, linkedNode: LGraphNode, forceValue?: boolean) {
let changed = false;
const value = forceValue == null ? linkedNode.mode === this.modeOn : forceValue;
let name = `Enable ${linkedNode.title}`;
// Need to set initally
if (widget.name !== name) {
widget.name = `Enable ${linkedNode.title}`;
widget.options = {on: "yes", off: "no"};
widget.value = value;
(widget as any).doModeChange = (forceValue?: boolean, skipOtherNodeCheck?: boolean) => {
let newValue = forceValue == null ? linkedNode.mode === this.modeOff : forceValue;
if (skipOtherNodeCheck !== true) {
if (newValue && (this.properties?.["toggleRestriction"] as string)?.includes(" one")) {
for (const widget of this.widgets) {
(widget as any).doModeChange(false, true);
}
} else if (!newValue && this.properties?.["toggleRestriction"] === "always one") {
newValue = this.widgets.every((w) => !w.value || w === widget);
}
}
changeModeOfNodes(linkedNode, (newValue ? this.modeOn : this.modeOff))
widget.value = newValue;
};
widget.callback = () => {
(widget as any).doModeChange();
};
changed = true;
}
if (forceValue != null) {
const newMode = (forceValue ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4;
if (linkedNode.mode !== newMode) {
changeModeOfNodes(linkedNode, newMode);
changed = true;
}
}
return changed;
}
forceWidgetOff(widget: IWidget, skipOtherNodeCheck?: boolean) {
(widget as any).doModeChange(false, skipOtherNodeCheck);
}
forceWidgetOn(widget: IWidget, skipOtherNodeCheck?: boolean) {
(widget as any).doModeChange(true, skipOtherNodeCheck);
}
forceWidgetToggle(widget: IWidget, skipOtherNodeCheck?: boolean) {
(widget as any).doModeChange(!widget.value, skipOtherNodeCheck);
}
}

View File

@@ -0,0 +1,366 @@
import type {
LLink,
LGraphNode,
INodeOutputSlot,
INodeInputSlot,
ISerialisedNode,
IComboWidget,
IBaseWidget,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {api} from "scripts/api.js";
import {wait} from "rgthree/common/shared_utils.js";
import {rgthree} from "./rgthree.js";
/** Wraps a node instance keeping closure without mucking the finicky types. */
export class PowerPrompt {
readonly isSimple: boolean;
readonly node: LGraphNode;
readonly promptEl: HTMLTextAreaElement;
nodeData: ComfyNodeDef;
readonly combos: {[key: string]: IComboWidget} = {};
readonly combosValues: {[key: string]: string[]} = {};
boundOnFreshNodeDefs!: (event: CustomEvent) => void;
private configuring = false;
constructor(node: LGraphNode, nodeData: ComfyNodeDef) {
this.node = node;
this.node.properties = this.node.properties || {};
this.node.properties["combos_filter"] = "";
this.nodeData = nodeData;
this.isSimple = this.nodeData.name.includes("Simple");
this.promptEl = (node.widgets![0]! as any).inputEl;
this.addAndHandleKeyboardLoraEditWeight();
this.patchNodeRefresh();
const oldConfigure = this.node.configure;
this.node.configure = (info: ISerialisedNode) => {
this.configuring = true;
oldConfigure?.apply(this.node, [info]);
this.configuring = false;
};
const oldOnConnectionsChange = this.node.onConnectionsChange;
this.node.onConnectionsChange = (
type: number,
slotIndex: number,
isConnected: boolean,
link_info: LLink,
_ioSlot: INodeOutputSlot | INodeInputSlot,
) => {
oldOnConnectionsChange?.apply(this.node, [type, slotIndex, isConnected, link_info, _ioSlot]);
this.onNodeConnectionsChange(type, slotIndex, isConnected, link_info, _ioSlot);
};
const oldOnConnectInput = this.node.onConnectInput;
this.node.onConnectInput = (
inputIndex: number,
outputType: INodeOutputSlot["type"],
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
) => {
let canConnect = true;
if (oldOnConnectInput) {
canConnect = oldOnConnectInput.apply(this.node, [
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
]);
}
return (
this.configuring ||
!!rgthree.loadingApiJson ||
(canConnect && !this.node.inputs[inputIndex]!.disabled)
);
};
const oldOnConnectOutput = this.node.onConnectOutput;
this.node.onConnectOutput = (
outputIndex: number,
inputType: INodeInputSlot["type"],
inputSlot: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number,
) => {
let canConnect = true;
if (oldOnConnectOutput) {
canConnect = oldOnConnectOutput?.apply(this.node, [
outputIndex,
inputType,
inputSlot,
inputNode,
inputIndex,
]);
}
return (
this.configuring ||
!!rgthree.loadingApiJson ||
(canConnect && !this.node.outputs[outputIndex]!.disabled)
);
};
const onPropertyChanged = this.node.onPropertyChanged;
this.node.onPropertyChanged = (property: string, value: any, prevValue: any) => {
const v = onPropertyChanged && onPropertyChanged.call(this.node, property, value, prevValue);
if (property === "combos_filter") {
this.refreshCombos(this.nodeData);
}
return v ?? true;
};
// Strip all widgets but prompt (we'll re-add them in refreshCombos)
// this.node.widgets.splice(1);
for (let i = this.node.widgets!.length - 1; i >= 0; i--) {
if (this.shouldRemoveServerWidget(this.node.widgets![i]!)) {
this.node.widgets!.splice(i, 1);
}
}
this.refreshCombos(nodeData);
setTimeout(() => {
this.stabilizeInputsOutputs();
}, 32);
}
/**
* Cleans up optional out puts when we don't have the optional input. Purely a vanity function.
*/
onNodeConnectionsChange(
_type: number,
_slotIndex: number,
_isConnected: boolean,
_linkInfo: LLink,
_ioSlot: INodeOutputSlot | INodeInputSlot,
) {
this.stabilizeInputsOutputs();
}
private stabilizeInputsOutputs() {
// If we are currently "configuring" then skip this stabilization. The connected nodes may
// not yet be configured.
if (this.configuring || rgthree.loadingApiJson) {
return;
}
// If our first input is connected, then we can show the proper output.
const clipLinked = this.node.inputs.some((i) => i.name.includes("clip") && !!i.link);
const modelLinked = this.node.inputs.some((i) => i.name.includes("model") && !!i.link);
for (const output of this.node.outputs) {
const type = (output.type as string).toLowerCase();
if (type.includes("model")) {
output.disabled = !modelLinked;
} else if (type.includes("conditioning")) {
output.disabled = !clipLinked;
} else if (type.includes("clip")) {
output.disabled = !clipLinked;
} else if (type.includes("string")) {
// Our text prompt is always enabled, but let's color it so it stands out
// if the others are disabled. #7F7 is Litegraph's default.
output.color_off = "#7F7";
output.color_on = "#7F7";
}
if (output.disabled) {
// this.node.disconnectOutput(index);
}
}
}
onFreshNodeDefs(event: CustomEvent) {
this.refreshCombos(event.detail[this.nodeData.name]);
}
shouldRemoveServerWidget(widget: IBaseWidget) {
return (
widget.name?.startsWith("insert_") ||
widget.name?.startsWith("target_") ||
widget.name?.startsWith("crop_") ||
widget.name?.startsWith("values_")
);
}
refreshCombos(nodeData: ComfyNodeDef) {
this.nodeData = nodeData;
let filter: RegExp | null = null;
if ((this.node.properties["combos_filter"] as string)?.trim()) {
try {
filter = new RegExp((this.node.properties["combos_filter"] as string).trim(), "i");
} catch (e) {
console.error(`Could not parse "${filter}" for Regular Expression`, e);
filter = null;
}
}
// Add the combo for hidden inputs of nodeData
let data = Object.assign(
{},
this.nodeData.input?.optional || {},
this.nodeData.input?.hidden || {},
);
for (const [key, value] of Object.entries(data)) {
//Object.entries(this.nodeData.input?.hidden || {})) {
if (Array.isArray(value[0])) {
let values = value[0] as string[];
if (key.startsWith("insert")) {
values = filter
? values.filter(
(v, i) => i < 1 || (i == 1 && v.match(/^disable\s[a-z]/i)) || filter?.test(v),
)
: values;
const shouldShow =
values.length > 2 || (values.length > 1 && !values[1]!.match(/^disable\s[a-z]/i));
if (shouldShow) {
if (!this.combos[key]) {
this.combos[key] = this.node.addWidget(
"combo",
key,
values[0]!,
(selected) => {
if (selected !== values[0] && !selected.match(/^disable\s[a-z]/i)) {
// We wait a frame because if we use a keydown event to call, it'll wipe out
// the selection.
wait().then(() => {
if (key.includes("embedding")) {
this.insertSelectionText(`embedding:${selected}`);
} else if (key.includes("saved")) {
this.insertSelectionText(
this.combosValues[`values_${key}`]![values.indexOf(selected)]!,
);
} else if (key.includes("lora")) {
this.insertSelectionText(`<lora:${selected}:1.0>`);
}
this.combos[key]!.value = values[0]!;
});
}
},
{
values,
serialize: true, // Don't include this in prompt.
},
) as IComboWidget;
(this.combos[key]! as any).oldComputeSize = this.combos[key]!.computeSize;
let node = this.node;
this.combos[key]!.computeSize = function (width: number) {
const size = (this as any).oldComputeSize?.(width) || [
width,
LiteGraph.NODE_WIDGET_HEIGHT,
];
if (this === node.widgets![node.widgets!.length - 1]) {
size[1] += 10;
}
return size;
};
}
this.combos[key]!.options!.values = values;
this.combos[key]!.value = values[0]!;
} else if (!shouldShow && this.combos[key]) {
this.node.widgets!.splice(this.node.widgets!.indexOf(this.combos[key]!), 1);
delete this.combos[key];
}
} else if (key.startsWith("values")) {
this.combosValues[key] = values;
}
}
}
}
insertSelectionText(text: string) {
if (!this.promptEl) {
console.error("Asked to insert text, but no textbox found.");
return;
}
let prompt = this.promptEl.value;
// Use selectionEnd as the split; if we have highlighted text, then we likely don't want to
// overwrite it (we could have just deleted it more easily).
let first = prompt.substring(0, this.promptEl.selectionEnd).replace(/ +$/, "");
first = first + (["\n"].includes(first[first.length - 1]!) ? "" : first.length ? " " : "");
let second = prompt.substring(this.promptEl.selectionEnd).replace(/^ +/, "");
second = (["\n"].includes(second[0]!) ? "" : second.length ? " " : "") + second;
this.promptEl.value = first + text + second;
this.promptEl.focus();
this.promptEl.selectionStart = first.length;
this.promptEl.selectionEnd = first.length + text.length;
}
/**
* Adds a keydown event listener to our prompt so we can see if we're using the
* ctrl/cmd + up/down arrows shortcut. This kind of competes with the core extension
* "Comfy.EditAttention" but since that only handles parenthesis and listens on window, we should
* be able to intercept and cancel the bubble if we're doing the same action within the lora tag.
*/
addAndHandleKeyboardLoraEditWeight() {
this.promptEl.addEventListener("keydown", (event: KeyboardEvent) => {
// If we're not doing a ctrl/cmd + arrow key, then bail.
if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
if (!event.ctrlKey && !event.metaKey) return;
// Unfortunately, we can't see Comfy.EditAttention delta in settings, so we hardcode to 0.01.
// We can acutally do better too, let's make it .1 by default, and .01 if also holding shift.
const delta = event.shiftKey ? 0.01 : 0.1;
let start = this.promptEl.selectionStart;
let end = this.promptEl.selectionEnd;
let fullText = this.promptEl.value;
let selectedText = fullText.substring(start, end);
// We don't care about fully rewriting Comfy.EditAttention, we just want to see if our
// selected text is a lora, which will always start with "<lora:". So work backwards until we
// find something that we know can't be a lora, or a "<".
if (!selectedText) {
const stopOn = "<>()\r\n\t"; // Allow spaces, since they can be in the filename
if (fullText[start] == ">") {
start -= 2;
end -= 2;
}
if (fullText[end - 1] == "<") {
start += 2;
end += 2;
}
while (!stopOn.includes(fullText[start]!) && start > 0) {
start--;
}
while (!stopOn.includes(fullText[end - 1]!) && end < fullText.length) {
end++;
}
selectedText = fullText.substring(start, end);
}
// Bail if this isn't a lora.
if (!selectedText.startsWith("<lora:") || !selectedText.endsWith(">")) {
return;
}
let weight = Number(selectedText.match(/:(-?\d*(\.\d*)?)>$/)?.[1]) ?? 1;
weight += event.key === "ArrowUp" ? delta : -delta;
const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`);
// Handle the new value and cancel the bubble so Comfy.EditAttention doesn't also try.
this.promptEl.setRangeText(updatedText, start, end, "select");
event.preventDefault();
event.stopPropagation();
});
}
/**
* Patches over api.getNodeDefs in comfy's api.js to fire a custom event that we can listen to
* here and manually refresh our combos when a request comes in to fetch the node data; which
* only happens once at startup (but before custom nodes js runs), and then after clicking
* the "Refresh" button in the floating menu, which is what we care about.
*/
patchNodeRefresh() {
this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this);
api.addEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
const oldNodeRemoved = this.node.onRemoved;
this.node.onRemoved = () => {
oldNodeRemoved?.call(this.node);
api.removeEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
};
}
}

View File

@@ -0,0 +1,163 @@
import type {
LGraph,
LGraphCanvas,
LGraphNode,
Point,
CanvasMouseEvent,
Subgraph,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
import {NodeTypesString} from "./constants.js";
import {getClosestOrSelf, query} from "rgthree/common/utils_dom.js";
import {wait} from "rgthree/common/shared_utils.js";
import {findFromNodeForSubgraph} from "./utils.js";
/**
* A bookmark node. Can be placed anywhere in the workflow, and given a shortcut key that will
* navigate to that node, with it in the top-left corner.
*/
export class Bookmark extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.BOOKMARK;
static override title = NodeTypesString.BOOKMARK;
override comfyClass = NodeTypesString.BOOKMARK;
// Really silly, but Litegraph assumes we have at least one input/output... so we need to
// counteract it's computeSize calculation by offsetting the start.
static slot_start_y = -20;
// LiteGraph adds mroe spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
// override it with a setter and re-set it measured exactly as we want.
___collapsed_width: number = 0;
override isVirtualNode = true;
override serialize_widgets = true;
//@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
override get _collapsed_width() {
return this.___collapsed_width;
}
override set _collapsed_width(width: number) {
const canvas = app.canvas as LGraphCanvas;
const ctx = canvas.canvas.getContext("2d")!;
const oldFont = ctx.font;
ctx.font = canvas.title_text_font;
this.___collapsed_width = 40 + ctx.measureText(this.title).width;
ctx.font = oldFont;
}
readonly keypressBound;
constructor(title = Bookmark.title) {
super(title);
const nextShortcutChar = BOOKMARKS_SERVICE.getNextShortcut();
this.addWidget(
"text",
"shortcut_key",
nextShortcutChar,
(value: string, ...args) => {
value = value.trim()[0] || "1";
},
{
y: 8,
},
);
this.addWidget("number", "zoom", 1, (value: number) => {}, {
y: 8 + LiteGraph.NODE_WIDGET_HEIGHT + 4,
max: 2,
min: 0.5,
precision: 2,
});
this.keypressBound = this.onKeypress.bind(this);
this.title = "🔖";
this.onConstructed();
}
// override computeSize(out?: Vector2 | undefined): Vector2 {
// super.computeSize(out);
// const minHeight = (this.widgets?.length || 0) * (LiteGraph.NODE_WIDGET_HEIGHT + 4) + 16;
// this.size[1] = Math.max(minHeight, this.size[1]);
// }
get shortcutKey(): string {
return (this.widgets[0]?.value as string)?.toLocaleLowerCase() ?? "";
}
override onAdded(graph: LGraph): void {
KEY_EVENT_SERVICE.addEventListener("keydown", this.keypressBound as EventListener);
}
override onRemoved(): void {
KEY_EVENT_SERVICE.removeEventListener("keydown", this.keypressBound as EventListener);
}
onKeypress(event: CustomEvent<{originalEvent: KeyboardEvent}>) {
const originalEvent = event.detail.originalEvent;
const target = (originalEvent.target as HTMLElement)!;
if (getClosestOrSelf(target, 'input,textarea,[contenteditable="true"]')) {
return;
}
// Only the shortcut keys are held down, optionally including "shift".
if (KEY_EVENT_SERVICE.areOnlyKeysDown(this.widgets[0]!.value as string, true)) {
this.canvasToBookmark();
originalEvent.preventDefault();
originalEvent.stopPropagation();
}
}
/**
* Called from LiteGraph's `processMouseDown` after it would invoke the input box for the
* shortcut_key, so we check if it exists and then add our own event listener so we can track the
* keys down for the user. Note, blocks drag if the return is truthy.
*/
override onMouseDown(event: CanvasMouseEvent, pos: Point, graphCanvas: LGraphCanvas): boolean {
const input = query<HTMLInputElement>(".graphdialog > input.value");
if (input && input.value === this.widgets[0]?.value) {
input.addEventListener("keydown", (e) => {
// ComfyUI swallows keydown on inputs, so we need to call out to rgthree to use downkeys.
KEY_EVENT_SERVICE.handleKeyDownOrUp(e);
e.preventDefault();
e.stopPropagation();
input.value = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" + ");
});
}
return false;
}
async canvasToBookmark() {
const canvas = app.canvas as LGraphCanvas;
if (this.graph !== app.canvas.getCurrentGraph()) {
const subgraph = this.graph as Subgraph;
// At some point, ComfyUI made a second param for openSubgraph which appears to be the node
// that id double-clicked on to open the subgraph. We don't have that in the bookmark, so
// we'll look for it. Note, that when opening the root graph, this will be null (since there's
// no such node). It seems to still navigate fine, though there's a console error about
// proxyWidgets or something..
const fromNode = findFromNodeForSubgraph(subgraph.id);
canvas.openSubgraph(subgraph, fromNode!);
await wait(16);
}
// ComfyUI seemed to break us again, but couldn't repro. No reason to not check, I guess.
// https://github.com/rgthree/rgthree-comfy/issues/71
if (canvas?.ds?.offset) {
canvas.ds.offset[0] = -this.pos[0] + 16;
canvas.ds.offset[1] = -this.pos[1] + 40;
}
if (canvas?.ds?.scale != null) {
canvas.ds.scale = Number(this.widgets[1]!.value || 1);
}
canvas.setDirty(true, true);
}
}
app.registerExtension({
name: "rgthree.Bookmark",
registerCustomNodes() {
Bookmark.setUp();
},
});

View File

@@ -0,0 +1,52 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseNodeModeChanger} from "./base_node_mode_changer.js";
import {NodeTypesString} from "./constants.js";
const MODE_BYPASS = 4;
const MODE_ALWAYS = 0;
class BypasserNode extends BaseNodeModeChanger {
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
static override type = NodeTypesString.FAST_BYPASSER;
static override title = NodeTypesString.FAST_BYPASSER;
override comfyClass = NodeTypesString.FAST_BYPASSER;
override readonly modeOn = MODE_ALWAYS;
override readonly modeOff = MODE_BYPASS;
constructor(title = BypasserNode.title) {
super(title);
this.onConstructed();
}
override async handleAction(action: string) {
if (action === "Bypass all") {
for (const widget of this.widgets || []) {
this.forceWidgetOff(widget, true);
}
} else if (action === "Enable all") {
for (const widget of this.widgets || []) {
this.forceWidgetOn(widget, true);
}
} else if (action === "Toggle all") {
for (const widget of this.widgets || []) {
this.forceWidgetToggle(widget, true);
}
}
}
}
app.registerExtension({
name: "rgthree.Bypasser",
registerCustomNodes() {
BypasserNode.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == BypasserNode.title) {
(node as any)._tempWidth = node.size[0];
}
},
});

View File

@@ -0,0 +1,264 @@
import {app} from "scripts/app.js";
import {iconGear, iconStarFilled, logoRgthreeAsync} from "rgthree/common/media/svgs.js";
import {$el, empty} from "rgthree/common/utils_dom.js";
import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import {RgthreeConfigDialog} from "./config.js";
import {wait} from "rgthree/common/shared_utils.js";
let rgthreeButtonGroup: RgthreeComfyButtonGroup | null = null;
function addRgthreeTopBarButtons() {
if (!CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.enabled")) {
if (rgthreeButtonGroup?.element?.parentElement) {
rgthreeButtonGroup.element.parentElement.removeChild(rgthreeButtonGroup.element);
}
return;
} else if (rgthreeButtonGroup) {
app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
return;
}
const buttons = [];
const rgthreeButton = new RgthreeComfyButton({
icon: "<svg></svg>",
tooltip: "rgthree-comfy",
primary: true,
// content: 'rgthree-comfy',
// app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
});
buttons.push(rgthreeButton);
logoRgthreeAsync().then((t) => {
rgthreeButton.setIcon(t);
});
rgthreeButton.withPopup(
new RgthreeComfyPopup(
{target: rgthreeButton.element},
$el("menu.rgthree-menu.rgthree-top-menu", {
children: [
$el("li", {
child: $el("button.rgthree-button-reset", {
html: iconGear + "Settings (rgthree-comfy)",
onclick: () => new RgthreeConfigDialog().show(),
}),
}),
$el("li", {
child: $el("button.rgthree-button-reset", {
html: iconStarFilled + "Star on Github",
onclick: () => window.open("https://github.com/rgthree/rgthree-comfy", "_blank"),
}),
}),
],
}),
),
"click",
);
if (CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.button_bookmarks.enabled")) {
const bookmarksListEl = $el("menu.rgthree-menu.rgthree-top-menu");
bookmarksListEl.appendChild(
$el("li.rgthree-message", {
child: $el("span", {text: "No bookmarks in current workflow."}),
}),
);
const bookmarksButton = new RgthreeComfyButton({
icon: "bookmark",
tooltip: "Workflow Bookmarks (rgthree-comfy)",
// app,
});
const bookmarksPopup = new RgthreeComfyPopup(
{target: bookmarksButton.element, modal: false},
bookmarksListEl,
);
bookmarksPopup.onOpen(() => {
const bookmarks = BOOKMARKS_SERVICE.getCurrentBookmarks();
empty(bookmarksListEl);
if (bookmarks.length) {
for (const b of bookmarks) {
bookmarksListEl.appendChild(
$el("li", {
child: $el("button.rgthree-button-reset", {
text: `[${b.shortcutKey}] ${b.title}`,
onclick: () => {
b.canvasToBookmark();
},
}),
}),
);
}
} else {
bookmarksListEl.appendChild(
$el("li.rgthree-message", {
child: $el("span", {text: "No bookmarks in current workflow."}),
}),
);
}
// bookmarksPopup.update();
});
bookmarksButton.withPopup(bookmarksPopup, "hover");
buttons.push(bookmarksButton);
}
rgthreeButtonGroup = new RgthreeComfyButtonGroup(...buttons);
app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
}
app.registerExtension({
name: "rgthree.TopMenu",
async setup() {
addRgthreeTopBarButtons();
CONFIG_SERVICE.addEventListener("config-change", ((e: CustomEvent) => {
if (e.detail?.key?.includes("features.comfy_top_bar_menu")) {
addRgthreeTopBarButtons();
}
}) as EventListener);
},
});
// The following are rough hacks since ComfyUI took away their button/buttongroup/popup
// functionality. TODO: Find a better spot to add rgthree controls to the UI, I suppose.
class RgthreeComfyButtonGroup {
element = $el("div.rgthree-comfybar-top-button-group");
buttons: RgthreeComfyButton[];
constructor(...buttons: RgthreeComfyButton[]) {
this.buttons = buttons;
this.update();
}
insert(button: RgthreeComfyButton, index: number) {
this.buttons.splice(index, 0, button);
this.update();
}
append(button: RgthreeComfyButton) {
this.buttons.push(button);
this.update();
}
remove(indexOrButton: RgthreeComfyButton | number) {
if (typeof indexOrButton !== "number") {
indexOrButton = this.buttons.indexOf(indexOrButton);
}
if (indexOrButton > -1) {
const btn = this.buttons.splice(indexOrButton, 1);
this.update();
return btn;
}
return null;
}
update() {
this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
}
}
interface RgthreeComfyButtonOptions {
icon?: string;
primary?: boolean;
overIcon?: string;
iconSize?: number;
content?: string | HTMLElement;
tooltip?: string;
enabled?: boolean;
action?: (e: Event, btn: RgthreeComfyButton) => void;
classList?: string;
visibilitySetting?: {id: string; showValue: any};
// app?: ComfyApp;
}
class RgthreeComfyButton {
element = $el("button.rgthree-comfybar-top-button.rgthree-button-reset.rgthree-button");
iconElement = $el("span.rgthree-button-icon");
constructor(opts: RgthreeComfyButtonOptions) {
opts.icon && this.setIcon(opts.icon);
opts.tooltip && this.element.setAttribute("title", opts.tooltip);
opts.primary && this.element.classList.add("-primary");
}
setIcon(iconOrMarkup: string) {
const markup = iconOrMarkup.startsWith("<")
? iconOrMarkup
: `<i class="mdi mdi-${iconOrMarkup}"></i>`;
this.iconElement.innerHTML = markup;
if (!this.iconElement.parentElement) {
this.element.appendChild(this.iconElement);
}
}
withPopup(popup: RgthreeComfyPopup, trigger: "click" | "hover") {
if (trigger === "click") {
this.element.addEventListener("click", () => {
popup.open();
});
}
if (trigger === "hover") {
this.element.addEventListener("pointerenter", () => {
popup.open();
});
}
}
}
interface RgthreeComfyPopupOptions {
target: HTMLElement;
classList?: string;
modal?: boolean;
}
class RgthreeComfyPopup {
element: HTMLElement;
target?: HTMLElement;
onOpenFn: (() => Promise<void> | void) | null = null;
opts: RgthreeComfyPopupOptions;
onWindowClickBound = this.onWindowClick.bind(this);
constructor(opts: RgthreeComfyPopupOptions, element: HTMLElement) {
this.element = element;
this.opts = opts;
opts.target && (this.target = opts.target);
opts.modal && this.element.classList.add("-modal");
}
async open() {
if (!this.target) {
throw new Error("No target for RgthreeComfyPopup");
}
if (this.onOpenFn) {
await this.onOpenFn();
}
await wait(16);
const rect = this.target.getBoundingClientRect();
this.element.setAttribute("state", "measuring");
document.body.appendChild(this.element);
this.element.style.position = "fixed";
this.element.style.left = `${rect.left}px`;
this.element.style.top = `${rect.top + rect.height}px`;
this.element.setAttribute("state", "open");
if (this.opts.modal) {
document.body.classList.add("rgthree-modal-menu-open");
}
window.addEventListener("click", this.onWindowClickBound);
}
close() {
this.element.remove();
document.body.classList.remove("rgthree-modal-menu-open");
window.removeEventListener("click", this.onWindowClickBound);
}
onOpen(fn: (() => void) | null) {
this.onOpenFn = fn;
}
onWindowClick() {
this.close();
}
}

View File

@@ -0,0 +1,407 @@
import {app} from "scripts/app.js";
import { RgthreeDialog, RgthreeDialogOptions } from "rgthree/common/dialog.js";
import { createElement as $el, queryAll as $$ } from "rgthree/common/utils_dom.js";
import { checkmark, logoRgthree } from "rgthree/common/media/svgs.js";
import { LogLevel, rgthree } from "./rgthree.js";
import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
/** Types of config used as a hint for the form handling. */
enum ConfigType {
UNKNOWN,
BOOLEAN,
STRING,
NUMBER,
ARRAY,
}
enum ConfigInputType {
UNKNOWN,
CHECKLIST, // Which is a multiselect array.
}
const TYPE_TO_STRING = {
[ConfigType.UNKNOWN]: "unknown",
[ConfigType.BOOLEAN]: "boolean",
[ConfigType.STRING]: "string",
[ConfigType.NUMBER]: "number",
[ConfigType.ARRAY]: "array",
};
type ConfigurationSchema = {
key: string;
type: ConfigType;
label: string;
inputType?: ConfigInputType,
options?: string[] | number[] | ConfigurationSchemaOption[];
description?: string;
subconfig?: ConfigurationSchema[];
isDevOnly?: boolean;
onSave?: (value: any) => void;
};
type ConfigurationSchemaOption = { value: any; label: string };
/**
* A static schema of sorts to layout options found in the config.
*/
const CONFIGURABLE: { [key: string]: ConfigurationSchema[] } = {
features: [
{
key: "features.progress_bar.enabled",
type: ConfigType.BOOLEAN,
label: "Prompt Progress Bar",
description: `Shows a minimal progress bar for nodes and steps at the top of the app.`,
subconfig: [
{
key: "features.progress_bar.height",
type: ConfigType.NUMBER,
label: "Height of the bar",
},
{
key: "features.progress_bar.position",
type: ConfigType.STRING,
label: "Position at top or bottom of window",
options: ["top", "bottom"],
},
],
},
{
key: "features.import_individual_nodes.enabled",
type: ConfigType.BOOLEAN,
label: "Import Individual Nodes Widgets",
description:
"Dragging & Dropping a similar image/JSON workflow onto (most) current workflow nodes" +
"will allow you to import that workflow's node's widgets when it has the same " +
"id and type. This is useful when you have several images and you'd like to import just " +
"one part of a previous iteration, like a seed, or prompt.",
},
],
menus: [
{
key: "features.comfy_top_bar_menu.enabled",
type: ConfigType.BOOLEAN,
label: "Enable Top Bar Menu",
description:
"Have quick access from ComfyUI's new top bar to rgthree-comfy bookmarks, settings " +
"(and more to come).",
},
{
key: "features.menu_queue_selected_nodes",
type: ConfigType.BOOLEAN,
label: "Show 'Queue Selected Output Nodes'",
description:
"Will show a menu item in the right-click context menus to queue (only) the selected " +
"output nodes.",
},
{
key: "features.menu_auto_nest.subdirs",
type: ConfigType.BOOLEAN,
label: "Auto Nest Subdirectories in Menus",
description:
"When a large, flat list of values contain sub-directories, auto nest them. (Like, for " +
"a large list of checkpoints).",
subconfig: [
{
key: "features.menu_auto_nest.threshold",
type: ConfigType.NUMBER,
label: "Number of items needed to trigger nesting.",
},
],
},
{
key: "features.menu_bookmarks.enabled",
type: ConfigType.BOOLEAN,
label: "Show Bookmarks in context menu",
description: "Will list bookmarks in the rgthree-comfy right-click context menu.",
},
],
groups: [
{
key: "features.group_header_fast_toggle.enabled",
type: ConfigType.BOOLEAN,
label: "Show fast toggles in Group Headers",
description: "Show quick toggles in Groups' Headers to quickly mute, bypass or queue.",
subconfig: [
{
key: "features.group_header_fast_toggle.toggles",
type: ConfigType.ARRAY,
label: "Which toggles to show.",
inputType: ConfigInputType.CHECKLIST,
options: [
{ value: "queue", label: "queue" },
{ value: "bypass", label: "bypass" },
{ value: "mute", label: "mute" },
],
},
{
key: "features.group_header_fast_toggle.show",
type: ConfigType.STRING,
label: "When to show them.",
options: [
{ value: "hover", label: "on hover" },
{ value: "always", label: "always" },
],
},
],
},
],
advanced: [
{
key: "features.show_alerts_for_corrupt_workflows",
type: ConfigType.BOOLEAN,
label: "Detect Corrupt Workflows",
description:
"Will show a message at the top of the screen when loading a workflow that has " +
"corrupt linking data.",
},
{
key: "log_level",
type: ConfigType.STRING,
label: "Log level for browser dev console.",
description:
"Further down the list, the more verbose logs to the console will be. For instance, " +
"selecting 'IMPORTANT' means only important message will be logged to the browser " +
"console, while selecting 'WARN' will log all messages at or higher than WARN, including " +
"'ERROR' and 'IMPORTANT' etc.",
options: ["IMPORTANT", "ERROR", "WARN", "INFO", "DEBUG", "DEV"],
isDevOnly: true,
onSave: function (value: LogLevel) {
rgthree.setLogLevel(value);
},
},
{
key: "features.invoke_extensions_async.node_created",
type: ConfigType.BOOLEAN,
label: "Allow other extensions to call nodeCreated on rgthree-nodes.",
isDevOnly: true,
description:
"Do not disable unless you are having trouble (and then file an issue at rgthree-comfy)." +
"Prior to Apr 2024 it was not possible for other extensions to invoke their nodeCreated " +
"event on some rgthree-comfy nodes. Now it's possible and this option is only here in " +
"for easy if something is wrong.",
},
],
};
/**
* Creates a new fieldrow for main or sub configuration items.
*/
function fieldrow(item: ConfigurationSchema) {
const initialValue = CONFIG_SERVICE.getConfigValue(item.key);
const container = $el(`div.fieldrow.-type-${TYPE_TO_STRING[item.type]}`, {
dataset: {
name: item.key,
initial: initialValue,
type: item.type,
},
});
$el(`label[for="${item.key}"]`, {
children: [
$el(`span[text="${item.label}"]`),
item.description ? $el("small", { html: item.description }) : null,
],
parent: container,
});
let input;
if (item.options?.length) {
if (item.inputType === ConfigInputType.CHECKLIST) {
const initialValueList = initialValue || [];
input = $el<HTMLSelectElement>(`fieldset.rgthree-checklist-group[id="${item.key}"]`, {
parent: container,
children: item.options.map((o) => {
const label = (o as ConfigurationSchemaOption).label || String(o);
const value = (o as ConfigurationSchemaOption).value || o;
const id = `${item.key}_${value}`;
return $el<HTMLSpanElement>(`span.rgthree-checklist-item`, {
children: [
$el<HTMLInputElement>(`input[type="checkbox"][value="${value}"]`, {
id,
checked: initialValueList.includes(value),
}),
$el<HTMLInputElement>(`label`, {
for: id,
text: label,
})
]
});
}),
});
} else {
input = $el<HTMLSelectElement>(`select[id="${item.key}"]`, {
parent: container,
children: item.options.map((o) => {
const label = (o as ConfigurationSchemaOption).label || String(o);
const value = (o as ConfigurationSchemaOption).value || o;
const valueSerialized = JSON.stringify({ value: value });
return $el<HTMLOptionElement>(`option[value="${valueSerialized}"]`, {
text: label,
selected: valueSerialized === JSON.stringify({ value: initialValue }),
});
}),
});
}
} else if (item.type === ConfigType.BOOLEAN) {
container.classList.toggle("-checked", !!initialValue);
input = $el<HTMLInputElement>(`input[type="checkbox"][id="${item.key}"]`, {
parent: container,
checked: initialValue,
});
} else {
input = $el(`input[id="${item.key}"]`, {
parent: container,
value: initialValue,
});
}
$el("div.fieldrow-value", { children: [input], parent: container });
return container;
}
/**
* A dialog to edit rgthree-comfy settings and config.
*/
export class RgthreeConfigDialog extends RgthreeDialog {
constructor() {
const content = $el("div");
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["features"]!, "Features"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["menus"]!, "Menus"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["groups"]!, "Groups"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["advanced"]!, "Advanced"));
content.addEventListener("input", (e) => {
const changed = this.getChangedFormData();
($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
!Object.keys(changed).length;
});
content.addEventListener("change", (e) => {
const changed = this.getChangedFormData();
($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
!Object.keys(changed).length;
});
const dialogOptions: RgthreeDialogOptions = {
class: "-iconed -settings",
title: logoRgthree + `<h2>Settings - rgthree-comfy</h2>`,
content,
onBeforeClose: () => {
const changed = this.getChangedFormData();
if (Object.keys(changed).length) {
return confirm("Looks like there are unsaved changes. Are you sure you want close?");
}
return true;
},
buttons: [
{
label: "Save",
disabled: true,
className: "rgthree-button save-button -blue",
callback: async (e) => {
const changed = this.getChangedFormData();
if (!Object.keys(changed).length) {
this.close();
return;
}
const success = await CONFIG_SERVICE.setConfigValues(changed);
if (success) {
for (const key of Object.keys(changed)) {
Object.values(CONFIGURABLE)
.flat()
.find((f) => f.key === key)
?.onSave?.(changed[key]);
}
this.close();
rgthree.showMessage({
id: "config-success",
message: `${checkmark} Successfully saved rgthree-comfy settings!`,
timeout: 4000,
});
($$(".save-button", this.element)[0] as HTMLButtonElement).disabled = true;
} else {
alert("There was an error saving rgthree-comfy configuration.");
}
},
},
],
};
super(dialogOptions);
}
private static buildFieldset(datas: ConfigurationSchema[], label: string) {
const fieldset = $el(`fieldset`, { children: [$el(`legend[text="${label}"]`)] });
for (const data of datas) {
if (data.isDevOnly && !rgthree.isDevMode()) {
continue;
}
const container = $el("div.formrow");
container.appendChild(fieldrow(data));
if (data.subconfig) {
for (const subfeature of data.subconfig) {
container.appendChild(fieldrow(subfeature));
}
}
fieldset.appendChild(container);
}
return fieldset;
}
getChangedFormData() {
return $$("[data-name]", this.contentElement).reduce((acc: { [key: string]: any }, el) => {
const name = el.dataset["name"]!;
const type = el.dataset["type"]!;
const initialValue = CONFIG_SERVICE.getConfigValue(name);
let currentValueEl = $$("fieldset.rgthree-checklist-group, input, textarea, select", el)[0] as HTMLInputElement;
let currentValue: any = null;
if (type === String(ConfigType.BOOLEAN)) {
currentValue = currentValueEl.checked;
// Not sure I like this side effect in here, but it's easy to just do it now.
el.classList.toggle("-checked", currentValue);
} else {
currentValue = currentValueEl?.value;
if (currentValueEl.nodeName === "SELECT") {
currentValue = JSON.parse(currentValue).value;
} else if (currentValueEl.classList.contains('rgthree-checklist-group')) {
currentValue = [];
for (const check of $$<HTMLInputElement>('input[type="checkbox"]', currentValueEl)) {
if (check.checked) {
currentValue.push(check.value);
}
}
} else if (type === String(ConfigType.NUMBER)) {
currentValue = Number(currentValue) || initialValue;
}
}
if (JSON.stringify(currentValue) !== JSON.stringify(initialValue)) {
acc[name] = currentValue;
}
return acc;
}, {});
}
}
app.ui.settings.addSetting({
id: "rgthree.config",
defaultValue: null,
name: "Open rgthree-comfy config",
type: () => {
// Adds a row to open the dialog from the ComfyUI settings.
return $el("tr.rgthree-comfyui-settings-row", {
children: [
$el("td", {
child: `<div>${logoRgthree} [rgthree-comfy] configuration / settings</div>`,
}),
$el("td", {
child: $el('button.rgthree-button.-blue[text="rgthree-comfy settings"]', {
events: {
click: (e: PointerEvent) => {
new RgthreeConfigDialog().show();
},
},
}),
}),
],
});
},
});

View File

@@ -0,0 +1,72 @@
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
export function addRgthree(str: string) {
return str + " (rgthree)";
}
export function stripRgthree(str: string) {
return str.replace(/\s*\(rgthree\)$/, "");
}
export const NodeTypesString = {
ANY_SWITCH: addRgthree("Any Switch"),
CONTEXT: addRgthree("Context"),
CONTEXT_BIG: addRgthree("Context Big"),
CONTEXT_SWITCH: addRgthree("Context Switch"),
CONTEXT_SWITCH_BIG: addRgthree("Context Switch Big"),
CONTEXT_MERGE: addRgthree("Context Merge"),
CONTEXT_MERGE_BIG: addRgthree("Context Merge Big"),
DYNAMIC_CONTEXT: addRgthree("Dynamic Context"),
DYNAMIC_CONTEXT_SWITCH: addRgthree("Dynamic Context Switch"),
DISPLAY_ANY: addRgthree("Display Any"),
IMAGE_OR_LATENT_SIZE: addRgthree("Image or Latent Size"),
NODE_MODE_RELAY: addRgthree("Mute / Bypass Relay"),
NODE_MODE_REPEATER: addRgthree("Mute / Bypass Repeater"),
FAST_MUTER: addRgthree("Fast Muter"),
FAST_BYPASSER: addRgthree("Fast Bypasser"),
FAST_GROUPS_MUTER: addRgthree("Fast Groups Muter"),
FAST_GROUPS_BYPASSER: addRgthree("Fast Groups Bypasser"),
FAST_ACTIONS_BUTTON: addRgthree("Fast Actions Button"),
LABEL: addRgthree("Label"),
POWER_PRIMITIVE: addRgthree("Power Primitive"),
POWER_PROMPT: addRgthree("Power Prompt"),
POWER_PROMPT_SIMPLE: addRgthree("Power Prompt - Simple"),
POWER_PUTER: addRgthree("Power Puter"),
POWER_CONDUCTOR: addRgthree("Power Conductor"),
SDXL_EMPTY_LATENT_IMAGE: addRgthree("SDXL Empty Latent Image"),
SDXL_POWER_PROMPT_POSITIVE: addRgthree("SDXL Power Prompt - Positive"),
SDXL_POWER_PROMPT_NEGATIVE: addRgthree("SDXL Power Prompt - Simple / Negative"),
POWER_LORA_LOADER: addRgthree("Power Lora Loader"),
KSAMPLER_CONFIG: addRgthree("KSampler Config"),
NODE_COLLECTOR: addRgthree("Node Collector"),
REROUTE: addRgthree("Reroute"),
RANDOM_UNMUTER: addRgthree("Random Unmuter"),
SEED: addRgthree("Seed"),
BOOKMARK: addRgthree("Bookmark"),
IMAGE_COMPARER: addRgthree("Image Comparer"),
IMAGE_INSET_CROP: addRgthree("Image Inset Crop"),
};
const UNRELEASED_KEYS = {
[NodeTypesString.DYNAMIC_CONTEXT]: "dynamic_context",
[NodeTypesString.DYNAMIC_CONTEXT_SWITCH]: "dynamic_context",
[NodeTypesString.POWER_CONDUCTOR]: "power_conductor",
};
/**
* Gets the list of nodes from NoteTypeString above, filtering any that are not applicable.
*/
export function getNodeTypeStrings() {
const unreleasedKeys = Object.keys(UNRELEASED_KEYS);
return Object.values(NodeTypesString)
.map((i) => stripRgthree(i))
.filter((i) => {
if (unreleasedKeys.includes(i)) {
return !!CONFIG_SERVICE.getConfigValue(`unreleased.${UNRELEASED_KEYS[i]}.enabled`)
}
return true;
})
.sort();
}

View File

@@ -0,0 +1,488 @@
import type {
INodeInputSlot,
INodeOutputSlot,
LGraphCanvas as TLGraphCanvas,
LGraphNode as TLGraphNode,
LLink,
ISlotType,
ConnectByTypeOptions,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {
IoDirection,
addConnectionLayoutSupport,
addMenuItem,
matchLocalSlotsToServer,
replaceNode,
} from "./utils.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {RgthreeBaseServerNodeConstructor} from "typings/rgthree.js";
import {debounce, wait} from "rgthree/common/shared_utils.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {NodeTypesString} from "./constants.js";
/**
* Takes a non-context node and determins for its input or output slot, if there is a valid
* connection for an opposite context output or input slot.
*/
function findMatchingIndexByTypeOrName(
otherNode: TLGraphNode,
otherSlot: INodeInputSlot | INodeOutputSlot,
ctxSlots: INodeInputSlot[] | INodeOutputSlot[],
) {
const otherNodeType = (otherNode.type || "").toUpperCase();
const otherNodeName = (otherNode.title || "").toUpperCase();
let otherSlotType = otherSlot.type as string;
if (Array.isArray(otherSlotType) || otherSlotType.includes(",")) {
otherSlotType = "COMBO";
}
const otherSlotName = otherSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
let ctxSlotIndex = -1;
if (["CONDITIONING", "INT", "STRING", "FLOAT", "COMBO"].includes(otherSlotType)) {
ctxSlotIndex = ctxSlots.findIndex((ctxSlot) => {
const ctxSlotName = ctxSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
let ctxSlotType = ctxSlot.type as string;
if (Array.isArray(ctxSlotType) || ctxSlotType.includes(",")) {
ctxSlotType = "COMBO";
}
if (ctxSlotType !== otherSlotType) {
return false;
}
// Straightforward matches.
if (
ctxSlotName === otherSlotName ||
(ctxSlotName === "SEED" && otherSlotName.includes("SEED")) ||
(ctxSlotName === "STEP_REFINER" && otherSlotName.includes("AT_STEP")) ||
(ctxSlotName === "STEP_REFINER" && otherSlotName.includes("REFINER_STEP"))
) {
return true;
}
// If postive other node, try to match conditining and text.
if (
(otherNodeType.includes("POSITIVE") || otherNodeName.includes("POSITIVE")) &&
((ctxSlotName === "POSITIVE" && otherSlotType === "CONDITIONING") ||
(ctxSlotName === "TEXT_POS_G" && otherSlotName.includes("TEXT_G")) ||
(ctxSlotName === "TEXT_POS_L" && otherSlotName.includes("TEXT_L")))
) {
return true;
}
if (
(otherNodeType.includes("NEGATIVE") || otherNodeName.includes("NEGATIVE")) &&
((ctxSlotName === "NEGATIVE" && otherSlotType === "CONDITIONING") ||
(ctxSlotName === "TEXT_NEG_G" && otherSlotName.includes("TEXT_G")) ||
(ctxSlotName === "TEXT_NEG_L" && otherSlotName.includes("TEXT_L")))
) {
return true;
}
return false;
});
} else {
ctxSlotIndex = ctxSlots.map((s) => s.type).indexOf(otherSlotType);
}
return ctxSlotIndex;
}
/**
* A Base Context node for other context based nodes to extend.
*/
export class BaseContextNode extends RgthreeBaseServerNode {
constructor(title: string) {
super(title);
}
// LiteGraph adds more spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
// override it with a setter and re-set it measured exactly as we want.
___collapsed_width: number = 0;
//@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
override get _collapsed_width() {
return this.___collapsed_width;
}
override set _collapsed_width(width: number) {
const canvas = app.canvas as TLGraphCanvas;
const ctx = canvas.canvas.getContext("2d")!;
const oldFont = ctx.font;
ctx.font = canvas.title_text_font;
let title = this.title.trim();
this.___collapsed_width = 30 + (title ? 10 + ctx.measureText(title).width : 0);
ctx.font = oldFont;
}
override connectByType(
slot: number | string,
targetNode: TLGraphNode,
targetSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
let canConnect = super.connectByType?.call(this, slot, targetNode, targetSlotType, optsIn);
if (!super.connectByType) {
canConnect = LGraphNode.prototype.connectByType.call(
this,
slot,
targetNode,
targetSlotType,
optsIn,
);
}
if (!canConnect && slot === 0) {
const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
// Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
// Unfortunately, we don't know which are null now, so we'll just connect any that are
// not already connected.
for (const [index, input] of (targetNode.inputs || []).entries()) {
if (input.link && !ctrlKey) {
continue;
}
const thisOutputSlot = findMatchingIndexByTypeOrName(targetNode, input, this.outputs);
if (thisOutputSlot > -1) {
this.connect(thisOutputSlot, targetNode, index);
}
}
}
return null;
}
override connectByTypeOutput(
slot: number | string,
sourceNode: TLGraphNode,
sourceSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
let canConnect = super.connectByTypeOutput?.call(
this,
slot,
sourceNode,
sourceSlotType,
optsIn,
);
if (!super.connectByType) {
canConnect = LGraphNode.prototype.connectByTypeOutput.call(
this,
slot,
sourceNode,
sourceSlotType,
optsIn,
);
}
if (!canConnect && slot === 0) {
const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
// Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
// Unfortunately, we don't know which are null now, so we'll just connect any that are
// not already connected.
for (const [index, output] of (sourceNode.outputs || []).entries()) {
if (output.links?.length && !ctrlKey) {
continue;
}
const thisInputSlot = findMatchingIndexByTypeOrName(sourceNode, output, this.inputs);
if (thisInputSlot > -1) {
sourceNode.connect(index, this, thisInputSlot);
}
}
}
return null;
}
static override setUp(
comfyClass: typeof LGraphNode,
nodeData: ComfyNodeDef,
ctxClass: RgthreeBaseServerNodeConstructor,
) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ctxClass);
// [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
// empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
// ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
// because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
// extension and we need to wait for that to happen.
wait(500).then(() => {
LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] =
LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] || [];
LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"].push((comfyClass as any).comfyClass);
});
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
addConnectionLayoutSupport(ctxClass, app, [
["Left", "Right"],
["Right", "Left"],
]);
setTimeout(() => {
ctxClass.category = comfyClass.category;
});
}
}
/**
* The original Context node.
*/
class ContextNode extends BaseContextNode {
static override title = NodeTypesString.CONTEXT;
static override type = NodeTypesString.CONTEXT;
static comfyClass = NodeTypesString.CONTEXT;
constructor(title = ContextNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextNode, app, {
name: "Convert To Context Big",
callback: (node) => {
replaceNode(node, ContextBigNode.type);
},
});
}
}
/**
* The Context Big node.
*/
class ContextBigNode extends BaseContextNode {
static override title = NodeTypesString.CONTEXT_BIG;
static override type = NodeTypesString.CONTEXT_BIG;
static comfyClass = NodeTypesString.CONTEXT_BIG;
constructor(title = ContextBigNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextBigNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextBigNode, app, {
name: "Convert To Context (Original)",
callback: (node) => {
replaceNode(node, ContextNode.type);
},
});
}
}
/**
* A base node for Context Switche nodes and Context Merges nodes that will always add another empty
* ctx input, no less than five.
*/
class BaseContextMultiCtxInputNode extends BaseContextNode {
private stabilizeBound = this.stabilize.bind(this);
constructor(title: string) {
super(title);
// Adding five. Note, configure will add as many as was in the stored workflow automatically.
this.addContextInput(5);
}
private addContextInput(num = 1) {
for (let i = 0; i < num; i++) {
this.addInput(`ctx_${String(this.inputs.length + 1).padStart(2, "0")}`, "RGTHREE_CONTEXT");
}
}
override onConnectionsChange(
type: number,
slotIndex: number,
isConnected: boolean,
link: LLink,
ioSlot: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.apply(this, [...arguments] as any);
if (type === LiteGraph.INPUT) {
this.scheduleStabilize();
}
}
private scheduleStabilize(ms = 64) {
return debounce(this.stabilizeBound, 64);
}
/**
* Stabilizes the inputs; removing any disconnected ones from the bottom, then adding an empty
* one to the end so we always have one empty one to expand.
*/
private stabilize() {
removeUnusedInputsFromEnd(this, 4);
this.addContextInput();
}
}
/**
* The Context Switch (original) node.
*/
class ContextSwitchNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_SWITCH;
static override type = NodeTypesString.CONTEXT_SWITCH;
static comfyClass = NodeTypesString.CONTEXT_SWITCH;
constructor(title = ContextSwitchNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextSwitchNode, app, {
name: "Convert To Context Switch Big",
callback: (node) => {
replaceNode(node, ContextSwitchBigNode.type);
},
});
}
}
/**
* The Context Switch Big node.
*/
class ContextSwitchBigNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_SWITCH_BIG;
static override type = NodeTypesString.CONTEXT_SWITCH_BIG;
static comfyClass = NodeTypesString.CONTEXT_SWITCH_BIG;
constructor(title = ContextSwitchBigNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchBigNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextSwitchBigNode, app, {
name: "Convert To Context Switch",
callback: (node) => {
replaceNode(node, ContextSwitchNode.type);
},
});
}
}
/**
* The Context Merge (original) node.
*/
class ContextMergeNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_MERGE;
static override type = NodeTypesString.CONTEXT_MERGE;
static comfyClass = NodeTypesString.CONTEXT_MERGE;
constructor(title = ContextMergeNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextMergeNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextMergeNode, app, {
name: "Convert To Context Merge Big",
callback: (node) => {
replaceNode(node, ContextMergeBigNode.type);
},
});
}
}
/**
* The Context Switch Big node.
*/
class ContextMergeBigNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_MERGE_BIG;
static override type = NodeTypesString.CONTEXT_MERGE_BIG;
static comfyClass = NodeTypesString.CONTEXT_MERGE_BIG;
constructor(title = ContextMergeBigNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextMergeBigNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextMergeBigNode, app, {
name: "Convert To Context Switch",
callback: (node) => {
replaceNode(node, ContextMergeNode.type);
},
});
}
}
const contextNodes = [
ContextNode,
ContextBigNode,
ContextSwitchNode,
ContextSwitchBigNode,
ContextMergeNode,
ContextMergeBigNode,
];
const contextTypeToServerDef: {[type: string]: ComfyNodeDef} = {};
function fixBadConfigs(node: ContextNode) {
// Dumb mistake, but let's fix our mispelling. This will probably need to stay in perpetuity to
// keep any old workflows operating.
const wrongName = node.outputs.find((o, i) => o.name === "CLIP_HEIGTH");
if (wrongName) {
wrongName.name = "CLIP_HEIGHT";
}
}
app.registerExtension({
name: "rgthree.Context",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
// Loop over out context nodes and see if any match the server data.
for (const ctxClass of contextNodes) {
if (nodeData.name === ctxClass.type) {
contextTypeToServerDef[ctxClass.type] = nodeData;
ctxClass.setUp(nodeType, nodeData);
break;
}
}
},
async nodeCreated(node: TLGraphNode) {
const type = node.type || (node.constructor as any).type;
const serverDef = type && contextTypeToServerDef[type];
if (serverDef) {
fixBadConfigs(node as ContextNode);
matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
// Switches don't need to change inputs, only context outputs
if (!type!.includes("Switch") && !type!.includes("Merge")) {
matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
}
// }, 100);
}
},
/**
* When we're loaded from the server, check if we're using an out of date version and update our
* inputs / outputs to match.
*/
async loadedGraphNode(node: TLGraphNode) {
const type = node.type || (node.constructor as any).type;
const serverDef = type && contextTypeToServerDef[type];
if (serverDef) {
fixBadConfigs(node as ContextNode);
matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
// Switches don't need to change inputs, only context outputs
if (!type!.includes("Switch") && !type!.includes("Merge")) {
matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
}
}
},
});

View File

@@ -0,0 +1,436 @@
import {RgthreeDialog, RgthreeDialogOptions} from "rgthree/common/dialog.js";
import {
createElement as $el,
empty,
appendChildren,
getClosestOrSelf,
query,
queryAll,
setAttributes,
} from "rgthree/common/utils_dom.js";
import {
logoCivitai,
link,
pencilColored,
diskColored,
dotdotdot,
} from "rgthree/common/media/svgs.js";
import {RgthreeModelInfo} from "typings/rgthree.js";
import {CHECKPOINT_INFO_SERVICE, LORA_INFO_SERVICE} from "rgthree/common/model_info_service.js";
import {rgthree} from "./rgthree.js";
import {MenuButton} from "rgthree/common/menu.js";
import {generateId, injectCss} from "rgthree/common/shared_utils.js";
import {rgthreeApi} from "rgthree/common/rgthree_api.js";
/**
* A dialog that displays information about a model/lora/etc.
*/
abstract class RgthreeInfoDialog extends RgthreeDialog {
private modifiedModelData = false;
private modelInfo: RgthreeModelInfo | null = null;
constructor(file: string) {
const dialogOptions: RgthreeDialogOptions = {
class: "rgthree-info-dialog",
title: `<h2>Loading...</h2>`,
content: "<center>Loading..</center>",
onBeforeClose: () => {
return true;
},
};
super(dialogOptions);
this.init(file);
}
abstract getModelInfo(file: string): Promise<RgthreeModelInfo | null>;
abstract refreshModelInfo(file: string): Promise<RgthreeModelInfo | null>;
abstract clearModelInfo(file: string): Promise<RgthreeModelInfo | null>;
private async init(file: string) {
const cssPromise = injectCss("rgthree/common/css/dialog_model_info.css");
this.modelInfo = await this.getModelInfo(file);
await cssPromise;
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
this.attachEvents();
}
protected override getCloseEventDetail(): {detail: any} {
const detail = {
dirty: this.modifiedModelData,
};
return {detail};
}
private attachEvents() {
this.contentElement.addEventListener("click", async (e: MouseEvent) => {
const target = getClosestOrSelf(e.target as HTMLElement, "[data-action]");
const action = target?.getAttribute("data-action");
if (!target || !action) {
return;
}
await this.handleEventAction(action, target, e);
});
}
private async handleEventAction(action: string, target: HTMLElement, e?: Event) {
const info = this.modelInfo!;
if (!info?.file) {
return;
}
if (action === "fetch-civitai") {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
} else if (action === "copy-trained-words") {
const selected = queryAll(".-rgthree-is-selected", target.closest("tr")!);
const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
await navigator.clipboard.writeText(text);
rgthree.showMessage({
id: "copy-trained-words-" + generateId(4),
type: "success",
message: `Successfully copied ${selected.length} key word${
selected.length === 1 ? "" : "s"
}.`,
timeout: 4000,
});
} else if (action === "toggle-trained-word") {
target?.classList.toggle("-rgthree-is-selected");
const tr = target.closest("tr");
if (tr) {
const span = query("td:first-child > *", tr)!;
let small = query("small", span);
if (!small) {
small = $el("small", {parent: span});
}
const num = queryAll(".-rgthree-is-selected", tr).length;
small.innerHTML = num
? `${num} selected | <span role="button" data-action="copy-trained-words">Copy</span>`
: "";
// this.handleEventAction('copy-trained-words', target, e);
}
} else if (action === "edit-row") {
const tr = target!.closest("tr")!;
const td = query("td:nth-child(2)", tr)!;
const input = td.querySelector("input,textarea");
if (!input) {
const fieldName = tr.dataset["fieldName"] as string;
tr.classList.add("-rgthree-editing");
const isTextarea = fieldName === "userNote";
const input = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, {
value: td.textContent,
});
input.addEventListener("keydown", (e) => {
if (!isTextarea && e.key === "Enter") {
const modified = saveEditableRow(info!, tr, true);
this.modifiedModelData = this.modifiedModelData || modified;
e.stopPropagation();
e.preventDefault();
} else if (e.key === "Escape") {
const modified = saveEditableRow(info!, tr, false);
this.modifiedModelData = this.modifiedModelData || modified;
e.stopPropagation();
e.preventDefault();
}
});
appendChildren(empty(td), [input]);
input.focus();
} else if (target!.nodeName.toLowerCase() === "button") {
const modified = saveEditableRow(info!, tr, true);
this.modifiedModelData = this.modifiedModelData || modified;
}
e?.preventDefault();
e?.stopPropagation();
}
}
private getInfoContent() {
const info = this.modelInfo || {};
const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
const html = `
<ul class="rgthree-info-area">
<li title="Type" class="rgthree-info-tag -type -type-${(
info.type || ""
).toLowerCase()}"><span>${info.type || ""}</span></li>
<li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(
info.baseModel || ""
).toLowerCase()}"><span>${info.baseModel || ""}</span></li>
<li class="rgthree-info-menu" stub="menu"></li>
${
""
// !civitaiLink
// ? ""
// : `
// <li title="Visit on Civitai" class="-link -civitai"><a href="${civitaiLink}" target="_blank">Civitai ${link}</a></li>
// `
}
</ul>
<table class="rgthree-info-table">
${infoTableRow("File", info.file || "")}
${infoTableRow("Hash (sha256)", info.sha256 || "")}
${
civitaiLink
? infoTableRow(
"Civitai",
`<a href="${civitaiLink}" target="_blank">${logoCivitai}View on Civitai</a>`,
)
: info.raw?.civitai?.error === "Model not found"
? infoTableRow(
"Civitai",
'<i>Model not found</i> <span class="-help" title="The model was not found on civitai with the sha256 hash. It\'s possible the model was removed, re-uploaded, or was never on civitai to begin with."></span>',
)
: info.raw?.civitai?.error
? infoTableRow("Civitai", info.raw?.civitai?.error)
: !info.raw?.civitai
? infoTableRow(
"Civitai",
`<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`,
)
: ""
}
${infoTableRow(
"Name",
info.name || info.raw?.metadata?.ss_output_name || "",
"The name for display.",
"name",
)}
${
!info.baseModelFile && !info.baseModelFile
? ""
: infoTableRow(
"Base Model",
(info.baseModel || "") + (info.baseModelFile ? ` (${info.baseModelFile})` : ""),
)
}
${
!info.trainedWords?.length
? ""
: infoTableRow(
"Trained Words",
getTrainedWordsMarkup(info.trainedWords) ?? "",
"Trained words from the metadata and/or civitai. Click to select for copy.",
)
}
${
!info.raw?.metadata?.ss_clip_skip || info.raw?.metadata?.ss_clip_skip == "None"
? ""
: infoTableRow("Clip Skip", info.raw?.metadata?.ss_clip_skip)
}
${infoTableRow(
"Strength Min",
info.strengthMin ?? "",
"The recommended minimum strength, In the Power Lora Loader node, strength will signal when it is below this threshold.",
"strengthMin",
)}
${infoTableRow(
"Strength Max",
info.strengthMax ?? "",
"The recommended maximum strength. In the Power Lora Loader node, strength will signal when it is above this threshold.",
"strengthMax",
)}
${
"" /*infoTableRow(
"User Tags",
info.userTags?.join(", ") ?? "",
"A list of tags to make filtering easier in the Power Lora Chooser.",
"userTags",
)*/
}
${infoTableRow(
"Additional Notes",
info.userNote ?? "",
"Additional notes you'd like to keep and reference in the info dialog.",
"userNote",
)}
</table>
<ul class="rgthree-info-images">${
info.images
?.map(
(img) => `
<li>
<figure>${
img.type === 'video'
? `<video src="${img.url}" autoplay loop></video>`
: `<img src="${img.url}" />`
}
<figcaption><!--
-->${imgInfoField(
"",
img.civitaiUrl
? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a>`
: undefined,
)}<!--
-->${imgInfoField("seed", img.seed)}<!--
-->${imgInfoField("steps", img.steps)}<!--
-->${imgInfoField("cfg", img.cfg)}<!--
-->${imgInfoField("sampler", img.sampler)}<!--
-->${imgInfoField("model", img.model)}<!--
-->${imgInfoField("positive", img.positive)}<!--
-->${imgInfoField("negative", img.negative)}<!--
--><!--${
""
// img.resources?.length
// ? `
// <tr><td>Resources</td><td><ul>
// ${(img.resources || [])
// .map(
// (r) => `
// <li>[${r.type || ""}] ${r.name || ""} ${
// r.weight != null ? `@ ${r.weight}` : ""
// }</li>
// `,
// )
// .join("")}
// </ul></td></tr>
// `
// : ""
}--></figcaption>
</figure>
</li>`,
)
.join("") ?? ""
}</ul>
`;
const div = $el("div", {html});
if (rgthree.isDevMode()) {
setAttributes(query('[stub="menu"]', div)!, {
children: [
new MenuButton({
icon: dotdotdot,
options: [
{label: "More Actions", type: "title"},
{
label: "Open API JSON",
callback: async (e: PointerEvent) => {
if (this.modelInfo?.file) {
window.open(
`rgthree/api/loras/info?file=${encodeURIComponent(this.modelInfo.file)}`,
);
}
},
},
{
label: "Clear all local info",
callback: async (e: PointerEvent) => {
if (this.modelInfo?.file) {
this.modelInfo = await LORA_INFO_SERVICE.clearFetchedInfo(this.modelInfo.file);
this.setContent(this.getInfoContent());
this.setTitle(
this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown",
);
}
},
},
],
}),
],
});
}
return div;
}
}
export class RgthreeLoraInfoDialog extends RgthreeInfoDialog {
override async getModelInfo(file: string) {
return LORA_INFO_SERVICE.getInfo(file, false, false);
}
override async refreshModelInfo(file: string) {
return LORA_INFO_SERVICE.refreshInfo(file);
}
override async clearModelInfo(file: string) {
return LORA_INFO_SERVICE.clearFetchedInfo(file);
}
}
export class RgthreeCheckpointInfoDialog extends RgthreeInfoDialog {
override async getModelInfo(file: string) {
return CHECKPOINT_INFO_SERVICE.getInfo(file, false, false);
}
override async refreshModelInfo(file: string) {
return CHECKPOINT_INFO_SERVICE.refreshInfo(file);
}
override async clearModelInfo(file: string) {
return CHECKPOINT_INFO_SERVICE.clearFetchedInfo(file);
}
}
/**
* Generates a uniform markup string for a table row.
*/
function infoTableRow(
name: string,
value: string | number,
help: string = "",
editableFieldName = "",
) {
return `
<tr class="${editableFieldName ? "editable" : ""}" ${
editableFieldName ? `data-field-name="${editableFieldName}"` : ""
}>
<td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
<td ${editableFieldName ? "" : 'colspan="2"'}>${
String(value).startsWith("<") ? value : `<span>${value}<span>`
}</td>
${
editableFieldName
? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>`
: ""
}
</tr>`;
}
function getTrainedWordsMarkup(words: RgthreeModelInfo["trainedWords"]) {
let markup = `<ul class="rgthree-info-trained-words-list">`;
for (const wordData of words || []) {
markup += `<li title="${wordData.word}" data-word="${
wordData.word
}" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
<span>${wordData.word}</span>
${wordData.civitai ? logoCivitai : ""}
${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
</li>`;
}
markup += `</ul>`;
return markup;
}
/**
* Saves / cancels an editable row. Returns a boolean if the data was modified.
*/
function saveEditableRow(info: RgthreeModelInfo, tr: HTMLElement, saving = true): boolean {
const fieldName = tr.dataset["fieldName"] as "file";
const input = query<HTMLInputElement>("input,textarea", tr)!;
let newValue = info[fieldName] ?? "";
let modified = false;
if (saving) {
newValue = input!.value;
if (fieldName.startsWith("strength")) {
if (Number.isNaN(Number(newValue))) {
alert(`You must enter a number into the ${fieldName} field.`);
return false;
}
newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
}
LORA_INFO_SERVICE.savePartialInfo(info.file!, {[fieldName]: newValue});
modified = true;
}
tr.classList.remove("-rgthree-editing");
const td = query("td:nth-child(2)", tr)!;
appendChildren(empty(td), [$el("span", {text: newValue})]);
return modified;
}
function imgInfoField(label: string, value?: string | number) {
return value != null ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
}

View File

@@ -0,0 +1,71 @@
import type {LGraphNodeConstructor, LGraphNode as TLGraphNode} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import type {ComfyApp} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {rgthree} from "./rgthree.js";
let hasShownAlertForUpdatingInt = false;
app.registerExtension({
name: "rgthree.DisplayAny",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef, app: ComfyApp) {
if (nodeData.name === "Display Any (rgthree)" || nodeData.name === "Display Int (rgthree)") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
(this as any).showValueWidget = ComfyWidgets["STRING"](
this,
"output",
["STRING", {multiline: true}],
app,
).widget;
(this as any).showValueWidget.inputEl!.readOnly = true;
(this as any).showValueWidget.serializeValue = async (node: TLGraphNode, index: number) => {
const n =
rgthree.getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff(node);
if (n) {
// Since we need a round trip to get the value, the serizalized value means nothing, and
// saving it to the metadata would just be confusing. So, we clear it here.
n.widgets_values![index] = "";
} else {
console.warn(
"No serialized node found in workflow. May be attributed to " +
"https://github.com/comfyanonymous/ComfyUI/issues/2193",
);
}
return "";
};
};
addConnectionLayoutSupport(nodeType as LGraphNodeConstructor, app, [["Left"], ["Right"]]);
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message: any) {
onExecuted?.apply(this, [message]);
(this as any).showValueWidget.value = message.text[0];
};
}
},
// This ports Display Int to DisplayAny, but ComfyUI still shows an error.
// If https://github.com/comfyanonymous/ComfyUI/issues/1527 is fixed, this could work.
// async loadedGraphNode(node: TLGraphNode) {
// if (node.type === "Display Int (rgthree)") {
// replaceNode(node, "Display Any (rgthree)", new Map([["input", "source"]]));
// if (!hasShownAlertForUpdatingInt) {
// hasShownAlertForUpdatingInt = true;
// setTimeout(() => {
// alert(
// "Don't worry, your 'Display Int' nodes have been updated to the new " +
// "'Display Any' nodes! You can ignore the error message underneath (for that node)." +
// "\n\nThanks.\n- rgthree",
// );
// }, 128);
// }
// }
// },
});

View File

@@ -0,0 +1,302 @@
import type {
IContextMenuValue,
IFoundSlot,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LGraphNode,
LLink,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {
IoDirection,
followConnectionUntilType,
getConnectedInputInfosAndFilterPassThroughs,
} from "./utils.js";
import {rgthree} from "./rgthree.js";
import {
SERVICE as CONTEXT_SERVICE,
InputMutation,
InputMutationOperation,
} from "./services/context_service.js";
import {NodeTypesString} from "./constants.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {DynamicContextNodeBase} from "./dynamic_context_base.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
const OWNED_PREFIX = "+";
const REGEX_OWNED_PREFIX = /^\+\s*/;
const REGEX_EMPTY_INPUT = /^\+\s*$/;
/**
* The Dynamic Context node.
*/
export class DynamicContextNode extends DynamicContextNodeBase {
static override title = NodeTypesString.DYNAMIC_CONTEXT;
static override type = NodeTypesString.DYNAMIC_CONTEXT;
static comfyClass = NodeTypesString.DYNAMIC_CONTEXT;
constructor(title = DynamicContextNode.title) {
super(title);
}
override onNodeCreated() {
this.addInput("base_ctx", "RGTHREE_DYNAMIC_CONTEXT");
this.ensureOneRemainingNewInputSlot();
super.onNodeCreated();
}
override onConnectionsChange(
type: ISlotType,
slotIndex: number,
isConnected: boolean,
link: LLink | null | undefined,
ioSlot: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, ioSlot);
if (this.configuring) {
return;
}
if (type === LiteGraph.INPUT) {
if (isConnected) {
this.handleInputConnected(slotIndex);
} else {
this.handleInputDisconnected(slotIndex);
}
}
}
override onConnectInput(
inputIndex: number,
outputType: INodeOutputSlot["type"],
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
): boolean {
let canConnect = true;
if (super.onConnectInput) {
canConnect = super.onConnectInput.apply(this, [...arguments] as any);
}
if (
canConnect &&
outputNode instanceof DynamicContextNode &&
outputIndex === 0 &&
inputIndex !== 0
) {
const [n, v] = rgthree.logger.warnParts(
"Currently, you can only connect a context node in the first slot.",
);
console[n]?.call(console, ...v);
canConnect = false;
}
return canConnect;
}
handleInputConnected(slotIndex: number) {
const ioSlot = this.inputs[slotIndex];
const connectedIndexes = [];
if (slotIndex === 0) {
let baseNodeInfos = getConnectedInputInfosAndFilterPassThroughs(this, this, 0);
const baseNodes = baseNodeInfos.map((n) => n.node)!;
const baseNodesDynamicCtx = baseNodes[0] as DynamicContextNodeBase;
if (baseNodesDynamicCtx?.provideInputsData) {
const inputsData = CONTEXT_SERVICE.getDynamicContextInputsData(baseNodesDynamicCtx);
console.log("inputsData", inputsData);
for (const input of baseNodesDynamicCtx.provideInputsData()) {
if (input.name === "base_ctx" || input.name === "+") {
continue;
}
this.addContextInput(input.name, input.type, input.index);
this.stabilizeNames();
}
}
} else if (this.isInputSlotForNewInput(slotIndex)) {
this.handleNewInputConnected(slotIndex);
}
}
isInputSlotForNewInput(slotIndex: number) {
const ioSlot = this.inputs[slotIndex];
return ioSlot && ioSlot.name === "+" && ioSlot.type === "*";
}
handleNewInputConnected(slotIndex: number) {
if (!this.isInputSlotForNewInput(slotIndex)) {
throw new Error('Expected the incoming slot index to be the "new input" input.');
}
const ioSlot = this.inputs[slotIndex]!;
let cxn = null;
if (ioSlot.link != null) {
cxn = followConnectionUntilType(this, IoDirection.INPUT, slotIndex, true);
}
if (cxn?.type && cxn?.name) {
let name = this.addOwnedPrefix(this.getNextUniqueNameForThisNode(cxn.name));
if (name.match(/^\+\s*[A-Z_]+(\.\d+)?$/)) {
name = name.toLowerCase();
}
ioSlot.name = name;
ioSlot.type = cxn.type as string;
ioSlot.removable = true;
while (!this.outputs[slotIndex]) {
this.addOutput("*", "*");
}
this.outputs[slotIndex]!.type = cxn.type as string;
this.outputs[slotIndex]!.name = this.stripOwnedPrefix(name).toLocaleUpperCase();
// This is a dumb override for ComfyUI's widgetinputs issues.
if (cxn.type === "COMBO" || cxn.type.includes(",") || Array.isArray(cxn.type)) {
(this.outputs[slotIndex] as any).widget = true;
}
this.inputsMutated({
operation: InputMutationOperation.ADDED,
node: this,
slotIndex,
slot: ioSlot,
});
this.stabilizeNames();
this.ensureOneRemainingNewInputSlot();
}
}
handleInputDisconnected(slotIndex: number) {
const inputs = this.getContextInputsList();
if (slotIndex === 0) {
for (let index = inputs.length - 1; index > 0; index--) {
if (index === 0 || index === inputs.length - 1) {
continue;
}
const input = inputs[index]!;
if (!this.isOwnedInput(input.name)) {
if (input.link || this.outputs[index]?.links?.length) {
this.renameContextInput(index, input.name, true);
} else {
this.removeContextInput(index);
}
}
}
this.setSize(this.computeSize());
this.setDirtyCanvas(true, true);
}
}
ensureOneRemainingNewInputSlot() {
removeUnusedInputsFromEnd(this, 1, REGEX_EMPTY_INPUT);
this.addInput(OWNED_PREFIX, "*");
}
getNextUniqueNameForThisNode(desiredName: string) {
const inputs = this.getContextInputsList();
const allExistingKeys = inputs.map((i) => this.stripOwnedPrefix(i.name).toLocaleUpperCase());
desiredName = this.stripOwnedPrefix(desiredName);
let newName = desiredName;
let n = 0;
while (allExistingKeys.includes(newName.toLocaleUpperCase())) {
newName = `${desiredName}.${++n}`;
}
return newName;
}
override removeInput(slotIndex: number) {
const slot = this.inputs[slotIndex]!;
super.removeInput(slotIndex);
if (this.outputs[slotIndex]) {
this.removeOutput(slotIndex);
}
this.inputsMutated({operation: InputMutationOperation.REMOVED, node: this, slotIndex, slot});
this.stabilizeNames();
}
stabilizeNames() {
const inputs = this.getContextInputsList();
const names: string[] = [];
for (const [index, input] of inputs.entries()) {
if (index === 0 || index === inputs.length - 1) {
continue;
}
input.label = undefined;
this.outputs[index]!.label = undefined;
let origName = this.stripOwnedPrefix(input.name).replace(/\.\d+$/, "");
let name = input.name;
if (!this.isOwnedInput(name)) {
names.push(name.toLocaleUpperCase());
} else {
let n = 0;
name = this.addOwnedPrefix(origName);
while (names.includes(this.stripOwnedPrefix(name).toLocaleUpperCase())) {
name = `${this.addOwnedPrefix(origName)}.${++n}`;
}
names.push(this.stripOwnedPrefix(name).toLocaleUpperCase());
if (input.name !== name) {
this.renameContextInput(index, name);
}
}
}
}
override getSlotMenuOptions(slot: IFoundSlot): IContextMenuValue[] {
const editable = this.isOwnedInput(slot.input!.name) && this.type !== "*";
return [
{
content: "✏️ Rename Input",
disabled: !editable,
callback: () => {
var dialog = app.canvas.createDialog(
"<span class='name'>Name</span><input autofocus type='text'/><button>OK</button>",
{},
);
var dialogInput = dialog.querySelector("input")!;
if (dialogInput) {
dialogInput.value = this.stripOwnedPrefix(slot.input!.name || "");
}
var inner = () => {
this.handleContextMenuRenameInputDialog(slot.slot, dialogInput.value);
dialog.close();
};
dialog.querySelector("button")!.addEventListener("click", inner);
dialogInput.addEventListener("keydown", (e) => {
dialog.is_modified = true;
if (e.keyCode == 27) {
dialog.close();
} else if (e.keyCode == 13) {
inner();
} else if (e.keyCode != 13 && (e.target as HTMLElement)?.localName != "textarea") {
return;
}
e.preventDefault();
e.stopPropagation();
});
dialogInput.focus();
},
},
{
content: "🗑️ Delete Input",
disabled: !editable,
callback: () => {
this.removeInput(slot.slot);
},
},
];
}
handleContextMenuRenameInputDialog(slotIndex: number, value: string) {
app.graph.beforeChange();
this.renameContextInput(slotIndex, value);
this.stabilizeNames();
this.setDirtyCanvas(true, true);
app.graph.afterChange();
}
}
const contextDynamicNodes = [DynamicContextNode];
app.registerExtension({
name: "rgthree.DynamicContext",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
return;
}
if (nodeData.name === DynamicContextNode.type) {
DynamicContextNode.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,241 @@
import type {INodeInputSlot, LGraphNodeConstructor} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {BaseContextNode} from "./context.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {moveArrayItem, wait} from "rgthree/common/shared_utils.js";
import {RgthreeInvisibleWidget} from "./utils_widgets.js";
import {
getContextOutputName,
InputMutation,
InputMutationOperation,
} from "./services/context_service.js";
import {SERVICE as CONTEXT_SERVICE} from "./services/context_service.js";
const OWNED_PREFIX = "+";
const REGEX_OWNED_PREFIX = /^\+\s*/;
const REGEX_EMPTY_INPUT = /^\+\s*$/;
export type InputLike = {
name: string;
type: number | string;
label?: string;
link: number | null;
removable?: boolean;
boundingRect: any;
};
/**
* The base context node that contains some shared between DynamicContext nodes. Not labels
* `abstract` so we can reference `this` in static methods.
*/
export class DynamicContextNodeBase extends BaseContextNode {
protected readonly hasShadowInputs: boolean = false;
getContextInputsList(): InputLike[] {
return this.inputs;
}
provideInputsData() {
const inputs = this.getContextInputsList();
return inputs
.map((input, index) => ({
name: this.stripOwnedPrefix(input.name),
type: String(input.type),
index,
}))
.filter((i) => i.type !== "*");
}
addOwnedPrefix(name: string) {
return `+ ${this.stripOwnedPrefix(name)}`;
}
isOwnedInput(inputOrName: string | null | INodeInputSlot) {
const name = typeof inputOrName == "string" ? inputOrName : inputOrName?.name || "";
return REGEX_OWNED_PREFIX.test(name);
}
stripOwnedPrefix(name: string) {
return name.replace(REGEX_OWNED_PREFIX, "");
}
// handleUpstreamMutation(mutation: InputMutation) {
// throw new Error('handleUpstreamMutation not overridden!')
// }
handleUpstreamMutation(mutation: InputMutation) {
console.log(`[node ${this.id}] handleUpstreamMutation`, mutation);
if (mutation.operation === InputMutationOperation.ADDED) {
const slot = mutation.slot;
if (!slot) {
throw new Error("Cannot have an ADDED mutation without a provided slot data.");
}
this.addContextInput(
this.stripOwnedPrefix(slot.name),
slot.type as string,
mutation.slotIndex,
);
return;
}
if (mutation.operation === InputMutationOperation.REMOVED) {
const slot = mutation.slot;
if (!slot) {
throw new Error("Cannot have an REMOVED mutation without a provided slot data.");
}
this.removeContextInput(mutation.slotIndex);
return;
}
if (mutation.operation === InputMutationOperation.RENAMED) {
const slot = mutation.slot;
if (!slot) {
throw new Error("Cannot have an RENAMED mutation without a provided slot data.");
}
this.renameContextInput(mutation.slotIndex, slot.name);
return;
}
}
override clone() {
const cloned = super.clone()! as DynamicContextNodeBase;
while (cloned.inputs.length > 1) {
cloned.removeInput(cloned.inputs.length - 1);
}
while (cloned.widgets.length > 1) {
cloned.removeWidget(cloned.widgets.length - 1);
}
while (cloned.outputs.length > 1) {
cloned.removeOutput(cloned.outputs.length - 1);
}
return cloned;
}
/**
* Adds the basic output_keys widget. Should be called _after_ specific nodes setup their inputs
* or widgets.
*/
override onNodeCreated() {
const node = this;
this.addCustomWidget(
new RgthreeInvisibleWidget("output_keys", "RGTHREE_DYNAMIC_CONTEXT_OUTPUTS", "", () => {
return (node.outputs || [])
.map((o, i) => i > 0 && o.name)
.filter((n) => n !== false)
.join(",");
}),
);
}
addContextInput(name: string, type: string, slot = -1) {
const inputs = this.getContextInputsList();
if (this.hasShadowInputs) {
inputs.push({name, type, link: null, boundingRect: null});
} else {
this.addInput(name, type);
}
if (slot > -1) {
moveArrayItem(inputs, inputs.length - 1, slot);
} else {
slot = inputs.length - 1;
}
if (type !== "*") {
const output = this.addOutput(getContextOutputName(name), type);
if (type === "COMBO" || String(type).includes(",") || Array.isArray(type)) {
(output as any).widget = true;
}
if (slot > -1) {
moveArrayItem(this.outputs, this.outputs.length - 1, slot);
}
}
this.fixInputsOutputsLinkSlots();
this.inputsMutated({
operation: InputMutationOperation.ADDED,
node: this,
slotIndex: slot,
slot: inputs[slot]!,
});
}
removeContextInput(slotIndex: number) {
if (this.hasShadowInputs) {
const inputs = this.getContextInputsList();
const input = inputs.splice(slotIndex, 1)[0];
if (this.outputs[slotIndex]) {
this.removeOutput(slotIndex);
}
} else {
this.removeInput(slotIndex);
}
}
renameContextInput(index: number, newName: string, forceOwnBool: boolean | null = null) {
const inputs = this.getContextInputsList();
const input = inputs[index]!;
const oldName = input.name;
newName = this.stripOwnedPrefix(newName.trim() || this.getSlotDefaultInputLabel(index));
if (forceOwnBool === true || (this.isOwnedInput(oldName) && forceOwnBool !== false)) {
newName = this.addOwnedPrefix(newName);
}
if (oldName !== newName) {
input.name = newName;
input.removable = this.isOwnedInput(newName);
this.outputs[index]!.name = getContextOutputName(inputs[index]!.name);
this.inputsMutated({
node: this,
operation: InputMutationOperation.RENAMED,
slotIndex: index,
slot: input,
});
}
}
getSlotDefaultInputLabel(slotIndex: number) {
const inputs = this.getContextInputsList();
const input = inputs[slotIndex]!;
let defaultLabel = this.stripOwnedPrefix(input.name).toLowerCase();
return defaultLabel.toLocaleLowerCase();
}
inputsMutated(mutation: InputMutation) {
CONTEXT_SERVICE.onInputChanges(this, mutation);
}
fixInputsOutputsLinkSlots() {
if (!this.hasShadowInputs) {
const inputs = this.getContextInputsList();
for (let index = inputs.length - 1; index > 0; index--) {
const input = inputs[index]!;
if ((input === null || input === void 0 ? void 0 : input.link) != null) {
app.graph.links[input.link!]!.target_slot = index;
}
}
}
const outputs = this.outputs;
for (let index = outputs.length - 1; index > 0; index--) {
const output = outputs[index];
if (output) {
output.nameLocked = true;
for (const link of output.links || []) {
app.graph.links[link!]!.origin_slot = index;
}
}
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, this);
// [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
// empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
// ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
// because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
// extension and we need to wait for that to happen.
wait(500).then(() => {
LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] =
LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] || [];
const comfyClassStr = (comfyClass as LGraphNodeConstructor).comfyClass;
if (comfyClassStr) {
LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"].push(comfyClassStr);
}
});
}
}

View File

@@ -0,0 +1,214 @@
import type {
LGraphNode,
LLink,
LGraphCanvas,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {DynamicContextNodeBase, InputLike} from "./dynamic_context_base.js";
import {NodeTypesString} from "./constants.js";
import {
InputMutation,
SERVICE as CONTEXT_SERVICE,
getContextOutputName,
} from "./services/context_service.js";
import {getConnectedInputNodesAndFilterPassThroughs} from "./utils.js";
import {debounce, moveArrayItem} from "rgthree/common/shared_utils.js";
import {measureText} from "./utils_canvas.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
type ShadowInputData = {
node: LGraphNode;
slot: number;
shadowIndex: number;
shadowIndexIfShownSingularly: number;
shadowIndexFull: number;
nodeIndex: number;
type: string | -1;
name: string;
key: string;
// isDuplicatedBefore: boolean,
duplicatesBefore: number[];
duplicatesAfter: number[];
};
/**
* The Context Switch node.
*/
class DynamicContextSwitchNode extends DynamicContextNodeBase {
static override title = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
static override type = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
static comfyClass = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
protected override readonly hasShadowInputs = true;
// override hasShadowInputs = true;
/**
* We should be able to assume that `lastInputsList` is the input list after the last, major
* synchronous change. Which should mean, if we're handling a change that is currently live, but
* not represented in our node (like, an upstream node has already removed an input), then we
* should be able to compar the current InputList to this `lastInputsList`.
*/
lastInputsList: ShadowInputData[] = [];
private shadowInputs: (InputLike & {count: number})[] = [
{name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0, boundingRect: null},
];
constructor(title = DynamicContextSwitchNode.title) {
super(title);
}
override getContextInputsList() {
return this.shadowInputs;
}
override handleUpstreamMutation(mutation: InputMutation) {
this.scheduleHardRefresh();
}
override onConnectionsChange(
type: ISlotType,
slotIndex: number,
isConnected: boolean,
link: LLink | null | undefined,
inputOrOutput: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, inputOrOutput);
if (this.configuring) {
return;
}
if (type === LiteGraph.INPUT) {
this.scheduleHardRefresh();
}
}
scheduleHardRefresh(ms = 64) {
return debounce(() => {
this.refreshInputsAndOutputs();
}, ms);
}
override onNodeCreated() {
this.addInput("ctx_1", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_2", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_3", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_4", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_5", "RGTHREE_DYNAMIC_CONTEXT");
super.onNodeCreated();
}
override addContextInput(name: string, type: string, slot?: number): void {}
/**
* This is a "hard" refresh of the list, but looping over the actual context inputs, and
* recompiling the shadowInputs and outputs.
*/
private refreshInputsAndOutputs() {
const inputs: (InputLike & {count: number})[] = [
{name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0, boundingRect: null},
];
let numConnected = 0;
for (let i = 0; i < this.inputs.length; i++) {
const childCtxs = getConnectedInputNodesAndFilterPassThroughs(
this,
this,
i,
) as DynamicContextNodeBase[];
if (childCtxs.length > 1) {
throw new Error("How is there more than one input?");
}
const ctx = childCtxs[0];
if (!ctx) continue;
numConnected++;
const slotsData = CONTEXT_SERVICE.getDynamicContextInputsData(ctx);
console.log(slotsData);
for (const slotData of slotsData) {
const found = inputs.find(
(n) => getContextOutputName(slotData.name) === getContextOutputName(n.name),
);
if (found) {
found.count += 1;
continue;
}
inputs.push({
name: slotData.name,
type: slotData.type,
link: null,
count: 1,
boundingRect: null,
});
}
}
this.shadowInputs = inputs;
// First output is always CONTEXT, so "p" is the offset.
let i = 0;
for (i; i < this.shadowInputs.length; i++) {
const data = this.shadowInputs[i]!;
let existing = this.outputs.find(
(o) => getContextOutputName(o.name) === getContextOutputName(data.name),
);
if (!existing) {
existing = this.addOutput(getContextOutputName(data.name), data.type);
}
moveArrayItem(this.outputs, existing, i);
delete existing.rgthree_status;
if (data.count !== numConnected) {
existing.rgthree_status = "WARN";
}
}
while (this.outputs[i]) {
const output = this.outputs[i];
if (output?.links?.length) {
output.rgthree_status = "ERROR";
i++;
} else {
this.removeOutput(i);
}
}
this.fixInputsOutputsLinkSlots();
}
override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: LGraphCanvas): void {
const low_quality = (canvas?.ds?.scale ?? 1) < 0.6;
if (low_quality || this.size[0] <= 10) {
return;
}
let y = LiteGraph.NODE_SLOT_HEIGHT - 1;
const w = this.size[0];
ctx.save();
ctx.font = "normal " + LiteGraph.NODE_SUBTEXT_SIZE + "px Arial";
ctx.textAlign = "right";
for (const output of this.outputs) {
if (!output.rgthree_status) {
y += LiteGraph.NODE_SLOT_HEIGHT;
continue;
}
const x = w - 20 - measureText(ctx, output.name);
if (output.rgthree_status === "ERROR") {
ctx.fillText("🛑", x, y);
} else if (output.rgthree_status === "WARN") {
ctx.fillText("⚠️", x, y);
}
y += LiteGraph.NODE_SLOT_HEIGHT;
}
ctx.restore();
}
}
app.registerExtension({
name: "rgthree.DynamicContextSwitch",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
return;
}
if (nodeData.name === DynamicContextSwitchNode.type) {
DynamicContextSwitchNode.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,359 @@
import type {
LGraph,
LGraphNode,
ISerialisedNode,
IButtonWidget,
IComboWidget,
IWidget,
IBaseWidget,
} from "@comfyorg/frontend";
import type {ComfyApp} from "@comfyorg/frontend";
import type {RgthreeBaseVirtualNode} from "./base_node.js";
import {app} from "scripts/app.js";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {NodeTypesString} from "./constants.js";
import {addMenuItem, changeModeOfNodes} from "./utils.js";
import {rgthree} from "./rgthree.js";
const MODE_ALWAYS = 0;
const MODE_MUTE = 2;
const MODE_BYPASS = 4;
/**
* The Fast Actions Button.
*
* This adds a button that the user can connect any node to and then choose an action to take on
* that node when the button is pressed. Default actions are "Mute," "Bypass," and "Enable," but
* Nodes can expose actions additional actions that can then be called back.
*/
class FastActionsButton extends BaseAnyInputConnectedNode {
static override type = NodeTypesString.FAST_ACTIONS_BUTTON;
static override title = NodeTypesString.FAST_ACTIONS_BUTTON;
override comfyClass = NodeTypesString.FAST_ACTIONS_BUTTON;
readonly logger = rgthree.newLogSession("[FastActionsButton]");
static "@buttonText" = {type: "string"};
static "@shortcutModifier" = {
type: "combo",
values: ["ctrl", "alt", "shift"],
};
static "@shortcutKey" = {type: "string"};
static collapsible = false;
override readonly isVirtualNode = true;
override serialize_widgets = true;
readonly buttonWidget: IButtonWidget;
readonly widgetToData = new Map<IWidget, {comfy?: ComfyApp; node?: LGraphNode}>();
readonly nodeIdtoFunctionCache = new Map<number, string>();
readonly keypressBound;
readonly keyupBound;
private executingFromShortcut = false;
override properties!: BaseAnyInputConnectedNode["properties"] & {
buttonText: string;
shortcutModifier: string;
shortcutKey: string;
};
constructor(title?: string) {
super(title);
this.properties["buttonText"] = "🎬 Action!";
this.properties["shortcutModifier"] = "alt";
this.properties["shortcutKey"] = "";
this.buttonWidget = this.addWidget(
"button",
this.properties["buttonText"],
"",
() => {
this.executeConnectedNodes();
},
{serialize: false},
) as IButtonWidget;
this.keypressBound = this.onKeypress.bind(this);
this.keyupBound = this.onKeyup.bind(this);
this.onConstructed();
}
/** When we're given data to configure, like from a PNG or JSON. */
override configure(info: ISerialisedNode): void {
super.configure(info);
// Since we add the widgets dynamically, we need to wait to set their values
// with a short timeout.
setTimeout(() => {
if (info.widgets_values) {
for (let [index, value] of info.widgets_values.entries()) {
if (index > 0) {
if (typeof value === "string" && value.startsWith("comfy_action:")) {
value = value.replace("comfy_action:", "");
this.addComfyActionWidget(index, value);
}
if (this.widgets[index]) {
this.widgets[index]!.value = value;
}
}
}
}
}, 100);
}
override clone() {
const cloned = super.clone()!;
cloned.properties["buttonText"] = "🎬 Action!";
cloned.properties["shortcutKey"] = "";
return cloned;
}
override onAdded(graph: LGraph): void {
window.addEventListener("keydown", this.keypressBound);
window.addEventListener("keyup", this.keyupBound);
}
override onRemoved(): void {
window.removeEventListener("keydown", this.keypressBound);
window.removeEventListener("keyup", this.keyupBound);
}
async onKeypress(event: KeyboardEvent) {
const target = (event.target as HTMLElement)!;
if (
this.executingFromShortcut ||
target.localName == "input" ||
target.localName == "textarea"
) {
return;
}
if (
this.properties["shortcutKey"].trim() &&
this.properties["shortcutKey"].toLowerCase() === event.key.toLowerCase()
) {
const shortcutModifier = this.properties["shortcutModifier"];
let good = shortcutModifier === "ctrl" && event.ctrlKey;
good = good || (shortcutModifier === "alt" && event.altKey);
good = good || (shortcutModifier === "shift" && event.shiftKey);
good = good || (shortcutModifier === "meta" && event.metaKey);
if (good) {
setTimeout(() => {
this.executeConnectedNodes();
}, 20);
this.executingFromShortcut = true;
event.preventDefault();
event.stopImmediatePropagation();
app.canvas.dirty_canvas = true;
return false;
}
}
return;
}
onKeyup(event: KeyboardEvent) {
const target = (event.target as HTMLElement)!;
if (target.localName == "input" || target.localName == "textarea") {
return;
}
this.executingFromShortcut = false;
}
override onPropertyChanged(property: string, value: unknown, prevValue?: unknown) {
if (property == "buttonText" && typeof value === "string") {
this.buttonWidget.name = value;
}
if (property == "shortcutKey" && typeof value === "string") {
this.properties["shortcutKey"] = value.trim()[0]?.toLowerCase() ?? "";
}
return true;
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
let changed = false;
// Remove any widgets and data for widgets that are no longer linked.
for (const [widget, data] of this.widgetToData.entries()) {
if (!data.node) {
continue;
}
if (!linkedNodes.includes(data.node)) {
const index = this.widgets.indexOf(widget);
if (index > -1) {
this.widgetToData.delete(widget);
this.removeWidget(widget);
changed = true;
} else {
const [m, a] = this.logger.debugParts("Connected widget is not in widgets... weird.");
console[m]?.(...a);
}
}
}
const badNodes: LGraphNode[] = []; // Nodes that are deleted elsewhere may not exist in linkedNodes.
let indexOffset = 1; // Start with button, increment when we hit a non-node widget (like comfy)
for (const [index, node] of linkedNodes.entries()) {
// Sometimes linkedNodes is stale.
if (!node) {
const [m, a] = this.logger.debugParts("linkedNode provided that does not exist. ");
console[m]?.(...a);
badNodes.push(node);
continue;
}
let widgetAtSlot = this.widgets[index + indexOffset];
if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
indexOffset++;
widgetAtSlot = this.widgets[index + indexOffset];
}
if (!widgetAtSlot || this.widgetToData.get(widgetAtSlot)?.node?.id !== node.id) {
// Find the next widget that matches the node.
let widget: IWidget | null = null;
for (let i = index + indexOffset; i < this.widgets.length; i++) {
if (this.widgetToData.get(this.widgets[i]!)?.node?.id === node.id) {
widget = this.widgets.splice(i, 1)[0]!;
this.widgets.splice(index + indexOffset, 0, widget);
changed = true;
break;
}
}
if (!widget) {
// Add a widget at this spot.
const exposedActions: string[] = (node.constructor as any).exposedActions || [];
widget = this.addWidget("combo", node.title, "None", "", {
values: ["None", "Mute", "Bypass", "Enable", ...exposedActions],
}) as IWidget;
widget.serializeValue = async (_node: LGraphNode, _index: number) => {
return widget?.value;
};
this.widgetToData.set(widget, {node});
changed = true;
}
}
}
// Go backwards through widgets, and remove any that are not in out widgetToData
for (let i = this.widgets.length - 1; i > linkedNodes.length + indexOffset - 1; i--) {
const widgetAtSlot = this.widgets[i];
if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
continue;
}
this.removeWidget(widgetAtSlot);
changed = true;
}
return changed;
}
override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
widget = typeof widget === "number" ? this.widgets[widget] : widget;
if (widget && this.widgetToData.has(widget as IWidget)) {
this.widgetToData.delete(widget as IWidget);
}
super.removeWidget(widget);
}
/**
* Runs through the widgets, and executes the actions.
*/
async executeConnectedNodes() {
for (const widget of this.widgets) {
if (widget == this.buttonWidget) {
continue;
}
const action = widget.value;
const {comfy, node} = this.widgetToData.get(widget) ?? {};
if (comfy) {
if (action === "Queue Prompt") {
await comfy.queuePrompt(0);
}
continue;
}
if (node) {
if (action === "Mute") {
changeModeOfNodes(node, MODE_MUTE);
} else if (action === "Bypass") {
changeModeOfNodes(node, MODE_BYPASS);
} else if (action === "Enable") {
changeModeOfNodes(node, MODE_ALWAYS);
}
// If there's a handleAction, always call it.
if ((node as RgthreeBaseVirtualNode).handleAction) {
if (typeof action !== "string") {
throw new Error("Fast Actions Button action should be a string: " + action);
}
await (node as RgthreeBaseVirtualNode).handleAction(action);
}
this.graph?.change();
continue;
}
console.warn("Fast Actions Button has a widget without correct data.");
}
}
/**
* Adds a ComfyActionWidget at the provided slot (or end).
*/
addComfyActionWidget(slot?: number, value?: string) {
let widget = this.addWidget(
"combo",
"Comfy Action",
"None",
() => {
if (String(widget.value).startsWith("MOVE ")) {
this.widgets.push(this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
widget.value = String(widget.rgthree_lastValue);
} else if (String(widget.value).startsWith("REMOVE ")) {
this.removeWidget(widget);
}
widget.rgthree_lastValue = widget.value;
},
{
values: ["None", "Queue Prompt", "REMOVE Comfy Action", "MOVE to end"],
},
) as IComboWidget;
widget.rgthree_lastValue = value;
widget.serializeValue = async (_node: LGraphNode, _index: number) => {
return `comfy_app:${widget?.value}`;
};
this.widgetToData.set(widget, {comfy: app});
if (slot != null) {
this.widgets.splice(slot, 0, this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
}
return widget;
}
override onSerialize(serialised: ISerialisedNode) {
super.onSerialize?.(serialised);
for (let [index, value] of (serialised.widgets_values || []).entries()) {
if (this.widgets[index]?.name === "Comfy Action") {
serialised.widgets_values![index] = `comfy_action:${value}`;
}
}
}
static override setUp() {
super.setUp();
addMenuItem(this, app, {
name: " Append a Comfy Action",
callback: (nodeArg: LGraphNode) => {
(nodeArg as FastActionsButton).addComfyActionWidget();
},
});
}
}
app.registerExtension({
name: "rgthree.FastActionsButton",
registerCustomNodes() {
FastActionsButton.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == FastActionsButton.title) {
(node as FastActionsButton)._tempWidth = node.size[0];
}
},
});

View File

@@ -0,0 +1,38 @@
import type {Size} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {NodeTypesString} from "./constants.js";
import {BaseFastGroupsModeChanger} from "./fast_groups_muter.js";
/**
* Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export class FastGroupsBypasser extends BaseFastGroupsModeChanger {
static override type = NodeTypesString.FAST_GROUPS_BYPASSER;
static override title = NodeTypesString.FAST_GROUPS_BYPASSER;
override comfyClass = NodeTypesString.FAST_GROUPS_BYPASSER;
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
protected override helpActions = "bypass and enable";
override readonly modeOn = LiteGraph.ALWAYS;
override readonly modeOff = 4; // Used by Comfy for "bypass"
constructor(title = FastGroupsBypasser.title) {
super(title);
this.onConstructed();
}
}
app.registerExtension({
name: "rgthree.FastGroupsBypasser",
registerCustomNodes() {
FastGroupsBypasser.setUp();
},
loadedGraphNode(node: FastGroupsBypasser) {
if (node.type == FastGroupsBypasser.title) {
node.tempSize = [...node.size] as Size;
}
},
});

View File

@@ -0,0 +1,542 @@
import type {
LGraphNode,
LGraph as TLGraph,
LGraphCanvas as TLGraphCanvas,
Vector2,
Size,
LGraphGroup,
CanvasMouseEvent,
Point,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {SERVICE as FAST_GROUPS_SERVICE} from "./services/fast_groups_service.js";
import {drawNodeWidget, fitString} from "./utils_canvas.js";
import {RgthreeBaseWidget} from "./utils_widgets.js";
import { changeModeOfNodes, getGroupNodes } from "./utils.js";
const PROPERTY_SORT = "sort";
const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
const PROPERTY_MATCH_COLORS = "matchColors";
const PROPERTY_MATCH_TITLE = "matchTitle";
const PROPERTY_SHOW_NAV = "showNav";
const PROPERTY_SHOW_ALL_GRAPHS = "showAllGraphs";
const PROPERTY_RESTRICTION = "toggleRestriction";
/**
* Fast Muter implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export abstract class BaseFastGroupsModeChanger extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.FAST_GROUPS_MUTER;
static override title = NodeTypesString.FAST_GROUPS_MUTER;
static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
readonly modeOn: number = LiteGraph.ALWAYS;
readonly modeOff: number = LiteGraph.NEVER;
private debouncerTempWidth: number = 0;
tempSize: Vector2 | null = null;
// We don't need to serizalize since we'll just be checking group data on startup anyway
override serialize_widgets = false;
protected helpActions = "mute and unmute";
static "@matchColors" = {type: "string"};
static "@matchTitle" = {type: "string"};
static "@showNav" = {type: "boolean"};
static "@showAllGraphs" = {type: "boolean"};
static "@sort" = {
type: "combo",
values: ["position", "alphanumeric", "custom alphabet"],
};
static "@customSortAlphabet" = {type: "string"};
override properties!: RgthreeBaseVirtualNode["properties"] & {
[PROPERTY_MATCH_COLORS]: string;
[PROPERTY_MATCH_TITLE]: string;
[PROPERTY_SHOW_NAV]: boolean;
[PROPERTY_SHOW_ALL_GRAPHS]: boolean;
[PROPERTY_SORT]: string;
[PROPERTY_SORT_CUSTOM_ALPHA]: string;
[PROPERTY_RESTRICTION]: string;
};
static "@toggleRestriction" = {
type: "combo",
values: ["default", "max one", "always one"],
};
constructor(title = FastGroupsMuter.title) {
super(title);
this.properties[PROPERTY_MATCH_COLORS] = "";
this.properties[PROPERTY_MATCH_TITLE] = "";
this.properties[PROPERTY_SHOW_NAV] = true;
this.properties[PROPERTY_SHOW_ALL_GRAPHS] = true;
this.properties[PROPERTY_SORT] = "position";
this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
this.properties[PROPERTY_RESTRICTION] = "default";
}
override onConstructed(): boolean {
this.addOutput("OPT_CONNECTION", "*");
return super.onConstructed();
}
override onAdded(graph: TLGraph): void {
FAST_GROUPS_SERVICE.addFastGroupNode(this);
}
override onRemoved(): void {
FAST_GROUPS_SERVICE.removeFastGroupNode(this);
}
refreshWidgets() {
const canvas = app.canvas as TLGraphCanvas;
let sort = this.properties?.[PROPERTY_SORT] || "position";
let customAlphabet: string[] | null = null;
if (sort === "custom alphabet") {
const customAlphaStr = this.properties?.[PROPERTY_SORT_CUSTOM_ALPHA]?.replace(/\n/g, "");
if (customAlphaStr && customAlphaStr.trim()) {
customAlphabet = customAlphaStr.includes(",")
? customAlphaStr.toLocaleLowerCase().split(",")
: customAlphaStr.toLocaleLowerCase().trim().split("");
}
if (!customAlphabet?.length) {
sort = "alphanumeric";
customAlphabet = null;
}
}
const groups = [...FAST_GROUPS_SERVICE.getGroups(sort)];
// The service will return pre-sorted groups for alphanumeric and position. If this node has a
// custom sort, then we need to sort it manually.
if (customAlphabet?.length) {
groups.sort((a, b) => {
let aIndex = -1;
let bIndex = -1;
// Loop and find indexes. As we're finding multiple, a single for loop is more efficient.
for (const [index, alpha] of customAlphabet!.entries()) {
aIndex =
aIndex < 0 ? (a.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : aIndex;
bIndex =
bIndex < 0 ? (b.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : bIndex;
if (aIndex > -1 && bIndex > -1) {
break;
}
}
// Now compare.
if (aIndex > -1 && bIndex > -1) {
const ret = aIndex - bIndex;
if (ret === 0) {
return a.title.localeCompare(b.title);
}
return ret;
} else if (aIndex > -1) {
return -1;
} else if (bIndex > -1) {
return 1;
}
return a.title.localeCompare(b.title);
});
}
// See if we're filtering by colors, and match against the built-in keywords and actuial hex
// values.
let filterColors = (
(this.properties?.[PROPERTY_MATCH_COLORS] as string)?.split(",") || []
).filter((c) => c.trim());
if (filterColors.length) {
filterColors = filterColors.map((color) => {
color = color.trim().toLocaleLowerCase();
if (LGraphCanvas.node_colors[color]) {
color = LGraphCanvas.node_colors[color]!.groupcolor;
}
color = color.replace("#", "").toLocaleLowerCase();
if (color.length === 3) {
color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
}
return `#${color}`;
});
}
// Go over the groups
let index = 0;
for (const group of groups) {
if (filterColors.length) {
let groupColor = group.color?.replace("#", "").trim().toLocaleLowerCase();
if (!groupColor) {
continue;
}
if (groupColor.length === 3) {
groupColor = groupColor.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
}
groupColor = `#${groupColor}`;
if (!filterColors.includes(groupColor)) {
continue;
}
}
if (this.properties?.[PROPERTY_MATCH_TITLE]?.trim()) {
try {
if (!new RegExp(this.properties[PROPERTY_MATCH_TITLE], "i").exec(group.title)) {
continue;
}
} catch (e) {
console.error(e);
continue;
}
}
const showAllGraphs = this.properties?.[PROPERTY_SHOW_ALL_GRAPHS];
if (!showAllGraphs && group.graph !== app.canvas.getCurrentGraph()) {
continue;
}
let isDirty = false;
const widgetLabel = `Enable ${group.title}`;
let widget = this.widgets.find((w) => w.label === widgetLabel) as FastGroupsToggleRowWidget;
if (!widget) {
// When we add a widget, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
this.tempSize = [...this.size] as Size;
widget = this.addCustomWidget(
new FastGroupsToggleRowWidget(group, this),
) as FastGroupsToggleRowWidget;
this.setSize(this.computeSize());
isDirty = true;
}
if (widget.label != widgetLabel) {
widget.label = widgetLabel;
isDirty = true;
}
if (
group.rgthree_hasAnyActiveNode != null &&
widget.toggled != group.rgthree_hasAnyActiveNode
) {
widget.toggled = group.rgthree_hasAnyActiveNode;
isDirty = true;
}
if (this.widgets[index] !== widget) {
const oldIndex = this.widgets.findIndex((w) => w === widget);
this.widgets.splice(index, 0, this.widgets.splice(oldIndex, 1)[0]!);
isDirty = true;
}
if (isDirty) {
this.setDirtyCanvas(true, false);
}
index++;
}
// Everything should now be in order, so let's remove all remaining widgets.
while ((this.widgets || [])[index]) {
this.removeWidget(index++);
}
}
override computeSize(out?: Vector2) {
let size = super.computeSize(out);
if (this.tempSize) {
size[0] = Math.max(this.tempSize[0], size[0]);
size[1] = Math.max(this.tempSize[1], size[1]);
// We sometimes get repeated calls to compute size, so debounce before clearing.
this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
this.debouncerTempWidth = setTimeout(() => {
this.tempSize = null;
}, 32);
}
setTimeout(() => {
this.graph?.setDirtyCanvas(true, true);
}, 16);
return size;
}
override async handleAction(action: string) {
if (action === "Mute all" || action === "Bypass all") {
const alwaysOne = this.properties?.[PROPERTY_RESTRICTION] === "always one";
for (const [index, widget] of this.widgets.entries()) {
(widget as any)?.doModeChange(alwaysOne && !index ? true : false, true);
}
} else if (action === "Enable all") {
const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
for (const [index, widget] of this.widgets.entries()) {
(widget as any)?.doModeChange(onlyOne && index > 0 ? false : true, true);
}
} else if (action === "Toggle all") {
const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
let foundOne = false;
for (const [index, widget] of this.widgets.entries()) {
// If you have only one, then we'll stop at the first.
let newValue: boolean = onlyOne && foundOne ? false : !widget.value;
foundOne = foundOne || newValue;
(widget as any)?.doModeChange(newValue, true);
}
// And if you have always one, then we'll flip the last
if (!foundOne && this.properties?.[PROPERTY_RESTRICTION] === "always one") {
(this.widgets[this.widgets.length - 1] as any)?.doModeChange(true, true);
}
}
}
override getHelp() {
return `
<p>The ${this.type!.replace(
"(rgthree)",
"",
)} is an input-less node that automatically collects all groups in your current
workflow and allows you to quickly ${this.helpActions} all nodes within the group.</p>
<ul>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>${PROPERTY_MATCH_COLORS}</code> - Only add groups that match the provided
colors. Can be ComfyUI colors (red, pale_blue) or hex codes (#a4d399). Multiple can be
added, comma delimited.
</p></li>
<li><p>
<code>${PROPERTY_MATCH_TITLE}</code> - Filter the list of toggles by title match
(string match, or regular expression).
</p></li>
<li><p>
<code>${PROPERTY_SHOW_NAV}</code> - Add / remove a quick navigation arrow to take you
to the group. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SHOW_ALL_GRAPHS}</code> - Show groups from all [sub]graphs in the
workflow. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SORT}</code> - Sort the toggles' order by "alphanumeric", graph
"position", or "custom alphabet". <i>(default: "position")</i>
</p></li>
<li>
<p>
<code>${PROPERTY_SORT_CUSTOM_ALPHA}</code> - When the
<code>${PROPERTY_SORT}</code> property is "custom alphabet" you can define the
alphabet to use here, which will match the <i>beginning</i> of each group name and
sort against it. If group titles do not match any custom alphabet entry, then they
will be put after groups that do, ordered alphanumerically.
</p>
<p>
This can be a list of single characters, like "zyxw..." or comma delimited strings
for more control, like "sdxl,pro,sd,n,p".
</p>
<p>
Note, when two group title match the same custom alphabet entry, the <i>normal
alphanumeric alphabet</i> breaks the tie. For instance, a custom alphabet of
"e,s,d" will order groups names like "SDXL, SEGS, Detailer" eventhough the custom
alphabet has an "e" before "d" (where one may expect "SE" to be before "SD").
</p>
<p>
To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the
custom alphabet value of "se,s,f" would work here.
</p>
</li>
<li><p>
<code>${PROPERTY_RESTRICTION}</code> - Optionally, attempt to restrict the number of
widgets that can be enabled to a maximum of one, or always one.
</p>
<p><em><strong>Note:</strong> If using "max one" or "always one" then this is only
enforced when clicking a toggle on this node; if nodes within groups are changed
outside of the initial toggle click, then these restriction will not be enforced, and
could result in a state where more than one toggle is enabled. This could also happen
if nodes are overlapped with multiple groups.
</p></li>
</ul>
</li>
</ul>`;
}
}
/**
* Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export class FastGroupsMuter extends BaseFastGroupsModeChanger {
static override type = NodeTypesString.FAST_GROUPS_MUTER;
static override title = NodeTypesString.FAST_GROUPS_MUTER;
override comfyClass = NodeTypesString.FAST_GROUPS_MUTER;
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
protected override helpActions = "mute and unmute";
override readonly modeOn: number = LiteGraph.ALWAYS;
override readonly modeOff: number = LiteGraph.NEVER;
constructor(title = FastGroupsMuter.title) {
super(title);
this.onConstructed();
}
}
/**
* 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 FastGroupsToggleRowWidget extends RgthreeBaseWidget<{toggled: boolean}> {
override value = {toggled: false};
override options = {on: "yes", off: "no"};
override readonly type = "custom";
label: string = "";
group: LGraphGroup;
node: BaseFastGroupsModeChanger;
constructor(group: LGraphGroup, node: BaseFastGroupsModeChanger) {
super("RGTHREE_TOGGLE_AND_NAV");
this.group = group;
this.node = node;
}
doModeChange(force?: boolean, skipOtherNodeCheck?: boolean) {
this.group.recomputeInsideNodes();
const hasAnyActiveNodes = getGroupNodes(this.group).some((n) => n.mode === LiteGraph.ALWAYS);
let newValue = force != null ? force : !hasAnyActiveNodes;
if (skipOtherNodeCheck !== true) {
// TODO: This work should probably live in BaseFastGroupsModeChanger instead of the widgets.
if (newValue && this.node.properties?.[PROPERTY_RESTRICTION]?.includes(" one")) {
for (const widget of this.node.widgets) {
if (widget instanceof FastGroupsToggleRowWidget) {
widget.doModeChange(false, true);
}
}
} else if (!newValue && this.node.properties?.[PROPERTY_RESTRICTION] === "always one") {
newValue = this.node.widgets.every((w) => !w.value || w === this);
}
}
changeModeOfNodes(getGroupNodes(this.group), (newValue ? this.node.modeOn : this.node.modeOff));
this.group.rgthree_hasAnyActiveNode = newValue;
this.toggled = newValue;
this.group.graph?.setDirtyCanvas(true, false);
}
get toggled() {
return this.value.toggled;
}
set toggled(value: boolean) {
this.value.toggled = value;
}
toggle(value?: boolean) {
value = value == null ? !this.toggled : value;
if (value !== this.toggled) {
this.value.toggled = value;
this.doModeChange();
}
}
draw(
ctx: CanvasRenderingContext2D,
node: FastGroupsMuter,
width: number,
posY: number,
height: number,
) {
const widgetData = drawNodeWidget(ctx, {size: [width, height], pos: [15, posY]});
const showNav = node.properties?.[PROPERTY_SHOW_NAV] !== false;
// Render from right to left, since the text on left will take available space.
// `currentX` markes the current x position moving backwards.
let currentX = widgetData.width - widgetData.margin;
// The nav arrow
if (!widgetData.lowQuality && showNav) {
currentX -= 7; // Arrow space margin
const midY = widgetData.posY + widgetData.height * 0.5;
ctx.fillStyle = ctx.strokeStyle = "#89A";
ctx.lineJoin = "round";
ctx.lineCap = "round";
const arrow = new Path2D(`M${currentX} ${midY} l -7 6 v -3 h -7 v -6 h 7 v -3 z`);
ctx.fill(arrow);
ctx.stroke(arrow);
currentX -= 14;
currentX -= 7;
ctx.strokeStyle = widgetData.colorOutline;
ctx.stroke(new Path2D(`M ${currentX} ${widgetData.posY} v ${widgetData.height}`));
} else if (widgetData.lowQuality && showNav) {
currentX -= 28;
}
// The toggle itself.
currentX -= 7;
ctx.fillStyle = this.toggled ? "#89A" : "#333";
ctx.beginPath();
const toggleRadius = height * 0.36;
ctx.arc(currentX - toggleRadius, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
ctx.fill();
currentX -= toggleRadius * 2;
if (!widgetData.lowQuality) {
currentX -= 4;
ctx.textAlign = "right";
ctx.fillStyle = this.toggled ? widgetData.colorText : widgetData.colorTextSecondary;
const label = this.label;
const toggleLabelOn = this.options.on || "true";
const toggleLabelOff = this.options.off || "false";
ctx.fillText(this.toggled ? toggleLabelOn : toggleLabelOff, currentX, posY + height * 0.7);
currentX -= Math.max(
ctx.measureText(toggleLabelOn).width,
ctx.measureText(toggleLabelOff).width,
);
currentX -= 7;
ctx.textAlign = "left";
let maxLabelWidth = widgetData.width - widgetData.margin - 10 - (widgetData.width - currentX);
if (label != null) {
ctx.fillText(
fitString(ctx, label, maxLabelWidth),
widgetData.margin + 10,
posY + height * 0.7,
);
}
}
}
override serializeValue(node: LGraphNode, index: number) {
return this.value;
}
override mouse(event: CanvasMouseEvent, pos: Vector2, node: LGraphNode): boolean {
if (event.type == "pointerdown") {
if (node.properties?.[PROPERTY_SHOW_NAV] !== false && pos[0] >= node.size[0] - 15 - 28 - 1) {
const canvas = app.canvas as TLGraphCanvas;
const lowQuality = (canvas.ds?.scale || 1) <= 0.5;
if (!lowQuality) {
// Clicked on right half with nav arrow, go to the group, center on group and set
// zoom to see it all.
canvas.centerOnNode(this.group);
const zoomCurrent = canvas.ds?.scale || 1;
const zoomX = canvas.canvas.width / this.group._size[0] - 0.02;
const zoomY = canvas.canvas.height / this.group._size[1] - 0.02;
canvas.setZoom(Math.min(zoomCurrent, zoomX, zoomY), [
canvas.canvas.width / 2,
canvas.canvas.height / 2,
]);
canvas.setDirty(true, true);
}
} else {
this.toggle();
}
}
return true;
}
}
app.registerExtension({
name: "rgthree.FastGroupsMuter",
registerCustomNodes() {
FastGroupsMuter.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == FastGroupsMuter.title) {
(node as FastGroupsMuter).tempSize = [...node.size] as Point;
}
},
});

View File

@@ -0,0 +1,305 @@
import type {
LGraphCanvas as TLGraphCanvas,
LGraphGroup as TLGraphGroup,
LGraph as TLGraph,
Vector2,
CanvasMouseEvent,
} from "@comfyorg/frontend";
import type {AdjustedMouseCustomEvent} from "typings/rgthree.js";
import {app} from "scripts/app.js";
import {rgthree} from "./rgthree.js";
import {changeModeOfNodes, getGroupNodes, getOutputNodes} from "./utils.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
const BTN_SIZE = 20;
const BTN_MARGIN: Vector2 = [6, 6];
const BTN_SPACING = 8;
const BTN_GRID = BTN_SIZE / 8;
const TOGGLE_TO_MODE = new Map([
["MUTE", LiteGraph.NEVER],
["BYPASS", 4],
]);
function getToggles() {
return [...CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.toggles", [])].reverse();
}
/**
* Determines if the user clicked on an fast header icon.
*/
function clickedOnToggleButton(e: CanvasMouseEvent, group: TLGraphGroup): string | null {
const toggles = getToggles();
const pos = group.pos;
const size = group.size;
for (let i = 0; i < toggles.length; i++) {
const toggle = toggles[i];
if (
LiteGraph.isInsideRectangle(
e.canvasX,
e.canvasY,
pos[0] + size[0] - (BTN_SIZE + BTN_MARGIN[0]) * (i + 1),
pos[1] + BTN_MARGIN[1],
BTN_SIZE,
BTN_SIZE,
)
) {
return toggle;
}
}
return null;
}
/**
* Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
* quick, single-click ability to mute/bypass.
*/
app.registerExtension({
name: "rgthree.GroupHeaderToggles",
async setup() {
/**
* LiteGraph won't call `drawGroups` unless the canvas is dirty. Other nodes will do this, but
* in small workflows, we'll want to trigger it dirty so we can be drawn if we're in hover mode.
*/
setInterval(() => {
if (
CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") &&
CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always"
) {
app.canvas.setDirty(true, true);
}
}, 250);
/**
* Handles a click on the icon area if the user has the extension enable from settings.
* Hooks into the already overriden mouse down processor from rgthree.
*/
rgthree.addEventListener("on-process-mouse-down", ((e: AdjustedMouseCustomEvent) => {
if (!CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled")) return;
const canvas = app.canvas as TLGraphCanvas;
if (canvas.selected_group) {
const originalEvent = e.detail.originalEvent;
const group = canvas.selected_group;
const clickedOnToggle = clickedOnToggleButton(originalEvent, group) || "";
const toggleAction = clickedOnToggle?.toLocaleUpperCase();
if (toggleAction) {
console.log(toggleAction);
const nodes = getGroupNodes(group);
if (toggleAction === "QUEUE") {
const outputNodes = getOutputNodes(nodes);
if (!outputNodes?.length) {
rgthree.showMessage({
id: "no-output-in-group",
type: "warn",
timeout: 4000,
message: "No output nodes for group!",
});
} else {
rgthree.queueOutputNodes(outputNodes);
}
} else {
const toggleMode = TOGGLE_TO_MODE.get(toggleAction);
if (toggleMode) {
group.recomputeInsideNodes();
const hasAnyActiveNodes = nodes.some((n) => n.mode === LiteGraph.ALWAYS);
const isAllMuted =
!hasAnyActiveNodes && nodes.every((n) => n.mode === LiteGraph.NEVER);
const isAllBypassed =
!hasAnyActiveNodes && !isAllMuted && nodes.every((n) => n.mode === 4);
let newMode: 0 | 1 | 2 | 3 | 4 = LiteGraph.ALWAYS;
if (toggleMode === LiteGraph.NEVER) {
newMode = isAllMuted ? LiteGraph.ALWAYS : LiteGraph.NEVER;
} else {
newMode = isAllBypassed ? LiteGraph.ALWAYS : 4;
}
changeModeOfNodes(nodes, newMode);
}
}
// Make it such that we're not then moving the group on drag.
canvas.selected_group = null;
canvas.dragging_canvas = false;
}
}
}) as EventListener);
/**
* Overrides LiteGraph's Canvas method for drawingGroups and, after calling the original, checks
* that the user has enabled fast toggles and draws them on the top-right of the app..
*/
const drawGroups = LGraphCanvas.prototype.drawGroups;
LGraphCanvas.prototype.drawGroups = function (
canvasEl: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
) {
drawGroups.apply(this, [...arguments] as any);
if (
!CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") ||
!rgthree.lastCanvasMouseEvent
) {
return;
}
const graph = app.canvas.graph as TLGraph;
let groups: TLGraphGroup[];
// Default to hover if not always.
if (CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always") {
const hoverGroup = graph.getGroupOnPos(
rgthree.lastCanvasMouseEvent.canvasX,
rgthree.lastCanvasMouseEvent.canvasY,
);
groups = hoverGroup ? [hoverGroup] : [];
} else {
groups = graph._groups || [];
}
if (!groups.length) {
return;
}
const toggles = getToggles();
ctx.save();
for (const group of groups || []) {
const nodes = getGroupNodes(group);
let anyActive = false;
let allMuted = !!nodes.length;
let allBypassed = allMuted;
// Find the current state of the group's nodes.
for (const node of nodes) {
if (!(node instanceof LGraphNode)) continue;
anyActive = anyActive || node.mode === LiteGraph.ALWAYS;
allMuted = allMuted && node.mode === LiteGraph.NEVER;
allBypassed = allBypassed && node.mode === 4;
if (anyActive || (!allMuted && !allBypassed)) {
break;
}
}
// Display each toggle.
for (let i = 0; i < toggles.length; i++) {
const toggle = toggles[i];
const pos = group._pos;
const size = group._size;
ctx.fillStyle = ctx.strokeStyle = group.color || "#335";
const x = pos[0] + size[0] - BTN_MARGIN[0] - BTN_SIZE - (BTN_SPACING + BTN_SIZE) * i;
const y = pos[1] + BTN_MARGIN[1];
const midX = x + BTN_SIZE / 2;
const midY = y + BTN_SIZE / 2;
if (toggle === "queue") {
const outputNodes = getOutputNodes(nodes);
const oldGlobalAlpha = ctx.globalAlpha;
if (!outputNodes?.length) {
ctx.globalAlpha = 0.5;
}
ctx.lineJoin = "round";
ctx.lineCap = "round";
const arrowSizeX = BTN_SIZE * 0.6;
const arrowSizeY = BTN_SIZE * 0.7;
const arrow = new Path2D(
`M ${x + arrowSizeX / 2} ${midY} l 0 -${arrowSizeY / 2} l ${arrowSizeX} ${arrowSizeY / 2} l -${arrowSizeX} ${arrowSizeY / 2} z`,
);
ctx.stroke(arrow);
if (outputNodes?.length) {
ctx.fill(arrow);
}
ctx.globalAlpha = oldGlobalAlpha;
} else {
const on = toggle === "bypass" ? allBypassed : allMuted;
ctx.beginPath();
ctx.lineJoin = "round";
ctx.rect(x, y, BTN_SIZE, BTN_SIZE);
ctx.lineWidth = 2;
if (toggle === "mute") {
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (on) {
ctx.stroke(
new Path2D(`
${eyeFrame(midX, midY)}
${eyeLashes(midX, midY)}
`),
);
} else {
const radius = BTN_GRID * 1.5;
// Eyeball fill
ctx.fill(
new Path2D(`
${eyeFrame(midX, midY)}
${eyeFrame(midX, midY, -1)}
${circlePath(midX, midY, radius)}
${circlePath(midX + BTN_GRID / 2, midY - BTN_GRID / 2, BTN_GRID * 0.375)}
`),
"evenodd",
);
// Eye Outline Stroke
ctx.stroke(new Path2D(`${eyeFrame(midX, midY)} ${eyeFrame(midX, midY, -1)}`));
// Eye lashes (faded)
ctx.globalAlpha = this.editor_alpha * 0.5;
ctx.stroke(new Path2D(`${eyeLashes(midX, midY)} ${eyeLashes(midX, midY, -1)}`));
ctx.globalAlpha = this.editor_alpha;
}
} else {
const lineChanges = on
? `a ${BTN_GRID * 3}, ${BTN_GRID * 3} 0 1, 1 ${BTN_GRID * 3 * 2},0
l ${BTN_GRID * 2.0} 0`
: `l ${BTN_GRID * 8} 0`;
ctx.stroke(
new Path2D(`
M ${x} ${midY}
${lineChanges}
M ${x + BTN_SIZE} ${midY} l -2 2
M ${x + BTN_SIZE} ${midY} l -2 -2
`),
);
ctx.fill(new Path2D(`${circlePath(x + BTN_GRID * 3, midY, BTN_GRID * 1.8)}`));
}
}
}
}
ctx.restore();
};
},
});
function eyeFrame(midX: number, midY: number, yFlip = 1) {
return `
M ${midX - BTN_SIZE / 2} ${midY}
c ${BTN_GRID * 1.5} ${yFlip * BTN_GRID * 2.5}, ${BTN_GRID * (8 - 1.5)} ${
yFlip * BTN_GRID * 2.5
}, ${BTN_GRID * 8} 0
`;
}
function eyeLashes(midX: number, midY: number, yFlip = 1) {
return `
M ${midX - BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l -1.15 ${1.25 * yFlip}
M ${midX - BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l -0.90 ${1.5 * yFlip}
M ${midX - BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l -0.50 ${1.75 * yFlip}
M ${midX + BTN_GRID * 0.0} ${midY + yFlip * BTN_GRID * 2.0} l 0.00 ${2.0 * yFlip}
M ${midX + BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l 0.50 ${1.75 * yFlip}
M ${midX + BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l 0.90 ${1.5 * yFlip}
M ${midX + BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l 1.15 ${1.25 * yFlip}
`;
}
function circlePath(cx: number, cy: number, radius: number) {
return `
M ${cx} ${cy}
m ${radius}, 0
a ${radius},${radius} 0 1, 1 -${radius * 2},0
a ${radius},${radius} 0 1, 1 ${radius * 2},0
`;
}

View File

@@ -0,0 +1,85 @@
import type {INodeSlot, LGraphNode, LGraphNodeConstructor} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {tryToGetWorkflowDataFromEvent} from "rgthree/common/utils_workflow.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import {NodeTypesString} from "./constants.js";
/**
* Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
* quick, single-click ability to mute/bypass.
*/
app.registerExtension({
name: "rgthree.ImportIndividualNodes",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
const onDragOver = nodeType.prototype.onDragOver;
nodeType.prototype.onDragOver = function (e: DragEvent) {
let handled = onDragOver?.apply?.(this, [...arguments] as any);
if (handled != null) {
return handled;
}
return importIndividualNodesInnerOnDragOver(this, e);
};
const onDragDrop = nodeType.prototype.onDragDrop;
nodeType.prototype.onDragDrop = async function (e: DragEvent) {
const alreadyHandled = await onDragDrop?.apply?.(this, [...arguments] as any);
if (alreadyHandled) {
return alreadyHandled;
}
return importIndividualNodesInnerOnDragDrop(this, e);
};
},
});
export function importIndividualNodesInnerOnDragOver(node: LGraphNode, e: DragEvent): boolean {
return (
(node.widgets?.length && !!CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) ||
false
);
}
export async function importIndividualNodesInnerOnDragDrop(node: LGraphNode, e: DragEvent) {
if (!node.widgets?.length || !CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) {
return false;
}
const dynamicWidgetLengthNodes = [NodeTypesString.POWER_LORA_LOADER];
let handled = false;
const {workflow, prompt} = await tryToGetWorkflowDataFromEvent(e);
const exact = (workflow?.nodes || []).find(
(n: any) =>
n.id === node.id &&
n.type === node.type &&
(dynamicWidgetLengthNodes.includes(node.type) ||
n.widgets_values?.length === node.widgets_values?.length),
);
if (!exact) {
// If we tried, but didn't find an exact match, then allow user to stop the default behavior.
handled = !confirm(
"[rgthree-comfy] Could not find a matching node (same id & type) in the dropped workflow." +
" Would you like to continue with the default drop behaviour instead?",
);
} else if (!exact.widgets_values?.length) {
handled = !confirm(
"[rgthree-comfy] Matching node found (same id & type) but there's no widgets to set." +
" Would you like to continue with the default drop behaviour instead?",
);
} else if (
confirm(
"[rgthree-comfy] Found a matching node (same id & type) in the dropped workflow." +
" Would you like to set the widget values?",
)
) {
node.configure({
// Title is overridden if it's not supplied; set it to the current then.
title: node.title,
widgets_values: [...(exact?.widgets_values || [])],
mode: exact.mode,
} as any);
handled = true;
}
return handled;
}

View File

@@ -0,0 +1,480 @@
import {
LGraphCanvas,
LGraphNode,
Vector2,
LGraphNodeConstructor,
CanvasMouseEvent,
ISerialisedNode,
Point,
CanvasPointerEvent,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {api} from "scripts/api.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {RgthreeBaseHitAreas, RgthreeBaseWidget, RgthreeBaseWidgetBounds} from "./utils_widgets.js";
import {measureText} from "./utils_canvas.js";
type ComfyImageServerData = {filename: string; type: string; subfolder: string};
type ComfyImageData = {name: string; selected: boolean; url: string; img?: HTMLImageElement};
type OldExecutedPayload = {
images: ComfyImageServerData[];
};
type ExecutedPayload = {
a_images?: ComfyImageServerData[];
b_images?: ComfyImageServerData[];
};
function imageDataToUrl(data: ComfyImageServerData) {
return api.apiURL(
`/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}&subfolder=${
data.subfolder
}${app.getPreviewFormatParam()}${app.getRandParam()}`,
);
}
/**
* Compares two images in one canvas node.
*/
export class RgthreeImageComparer extends RgthreeBaseServerNode {
static override title = NodeTypesString.IMAGE_COMPARER;
static override type = NodeTypesString.IMAGE_COMPARER;
static comfyClass = NodeTypesString.IMAGE_COMPARER;
// These is what the core preview image node uses to show the context menu. May not be that helpful
// since it likely will always be "0" when a context menu is invoked without manually changing
// something.
override imageIndex: number = 0;
override imgs: InstanceType<typeof Image>[] = [];
override serialize_widgets = true;
isPointerDown = false;
isPointerOver = false;
pointerOverPos: Vector2 = [0, 0];
private canvasWidget: RgthreeImageComparerWidget | null = null;
static "@comparer_mode" = {
type: "combo",
values: ["Slide", "Click"],
};
constructor(title = RgthreeImageComparer.title) {
super(title);
this.properties["comparer_mode"] = "Slide";
}
override onExecuted(output: ExecutedPayload | OldExecutedPayload) {
super.onExecuted?.(output);
if ("images" in output) {
this.canvasWidget!.value = {
images: (output.images || []).map((d, i) => {
return {
name: i === 0 ? "A" : "B",
selected: true,
url: imageDataToUrl(d),
};
}),
};
} else {
output.a_images = output.a_images || [];
output.b_images = output.b_images || [];
const imagesToChoose: ComfyImageData[] = [];
const multiple = output.a_images.length + output.b_images.length > 2;
for (const [i, d] of output.a_images.entries()) {
imagesToChoose.push({
name: output.a_images.length > 1 || multiple ? `A${i + 1}` : "A",
selected: i === 0,
url: imageDataToUrl(d),
});
}
for (const [i, d] of output.b_images.entries()) {
imagesToChoose.push({
name: output.b_images.length > 1 || multiple ? `B${i + 1}` : "B",
selected: i === 0,
url: imageDataToUrl(d),
});
}
this.canvasWidget!.value = {images: imagesToChoose};
}
}
override onSerialize(serialised: ISerialisedNode) {
super.onSerialize && super.onSerialize(serialised);
for (let [index, widget_value] of (serialised.widgets_values || []).entries()) {
if (this.widgets[index]?.name === "rgthree_comparer") {
serialised.widgets_values![index] = (
this.widgets[index] as unknown as RgthreeImageComparerWidget
).value.images.map((d) => {
d = {...d};
delete d.img;
return d;
});
}
}
}
override onNodeCreated() {
this.canvasWidget = this.addCustomWidget(
new RgthreeImageComparerWidget("rgthree_comparer", this),
) as RgthreeImageComparerWidget;
this.setSize(this.computeSize());
this.setDirtyCanvas(true, true);
}
/**
* Sets mouse as down or up based on param. If it's down, we also loop to check pointer is still
* down. This is because LiteGraph doesn't fire `onMouseUp` every time there's a mouse up, so we
* need to manually monitor `pointer_is_down` and, when it's no longer true, set mouse as up here.
*/
private setIsPointerDown(down: boolean = this.isPointerDown) {
const newIsDown = down && !!app.canvas.pointer_is_down;
if (this.isPointerDown !== newIsDown) {
this.isPointerDown = newIsDown;
this.setDirtyCanvas(true, false);
}
this.imageIndex = this.isPointerDown ? 1 : 0;
if (this.isPointerDown) {
requestAnimationFrame(() => {
this.setIsPointerDown();
});
}
}
override onMouseDown(event: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): boolean {
super.onMouseDown?.(event, pos, canvas);
this.setIsPointerDown(true);
return false;
}
override onMouseEnter(event: CanvasPointerEvent): void {
super.onMouseEnter?.(event);
this.setIsPointerDown(!!app.canvas.pointer_is_down);
this.isPointerOver = true;
}
override onMouseLeave(event: CanvasPointerEvent): void {
super.onMouseLeave?.(event);
this.setIsPointerDown(false);
this.isPointerOver = false;
}
override onMouseMove(event: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): void {
super.onMouseMove?.(event, pos, canvas);
this.pointerOverPos = [...pos] as Point;
this.imageIndex = this.pointerOverPos[0] > this.size[0] / 2 ? 1 : 0;
}
override getHelp(): string {
return `
<p>
The ${this.type!.replace("(rgthree)", "")} node compares two images on top of each other.
</p>
<ul>
<li>
<p>
<strong>Notes</strong>
</p>
<ul>
<li><p>
The right-click menu may show image options (Open Image, Save Image, etc.) which will
correspond to the first image (image_a) if clicked on the left-half of the node, or
the second image if on the right half of the node.
</p></li>
</ul>
</li>
<li>
<p>
<strong>Inputs</strong>
</p>
<ul>
<li><p>
<code>image_a</code> <i>Optional.</i> The first image to use to compare.
image_a.
</p></li>
<li><p>
<code>image_b</code> <i>Optional.</i> The second image to use to compare.
</p></li>
<li><p>
<b>Note</b> <code>image_a</code> and <code>image_b</code> work best when a single
image is provided. However, if each/either are a batch, you can choose which item
from each batch are chosen to be compared. If either <code>image_a</code> or
<code>image_b</code> are not provided, the node will choose the first two from the
provided input if it's a batch, otherwise only show the single image (just as
Preview Image would).
</p></li>
</ul>
</li>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>comparer_mode</code> - Choose between "Slide" and "Click". Defaults to "Slide".
</p></li>
</ul>
</li>
</ul>`;
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeImageComparer);
}
static override onRegisteredForOverride(comfyClass: any) {
addConnectionLayoutSupport(RgthreeImageComparer, app, [
["Left", "Right"],
["Right", "Left"],
]);
setTimeout(() => {
RgthreeImageComparer.category = comfyClass.category;
});
}
}
type RgthreeImageComparerWidgetValue = {
images: ComfyImageData[];
};
class RgthreeImageComparerWidget extends RgthreeBaseWidget<RgthreeImageComparerWidgetValue> {
override readonly type = "custom";
private node: RgthreeImageComparer;
protected override hitAreas: RgthreeBaseHitAreas<any> = {
// We dynamically set this when/if we draw the labels.
};
private selected: [ComfyImageData?, ComfyImageData?] = [];
constructor(name: string, node: RgthreeImageComparer) {
super(name);
this.node = node;
}
private _value: RgthreeImageComparerWidgetValue = {images: []};
set value(v: RgthreeImageComparerWidgetValue) {
// Despite `v` typed as RgthreeImageComparerWidgetValue, we may have gotten an array of strings
// from previous versions. We can handle that gracefully.
let cleanedVal;
if (Array.isArray(v)) {
cleanedVal = v.map((d, i) => {
if (!d || typeof d === "string") {
// We usually only have two here, so they're selected.
d = {url: d, name: i == 0 ? "A" : "B", selected: true};
}
return d;
});
} else {
cleanedVal = v.images || [];
}
// If we have multiple items in our sent value but we don't have both an "A" and a "B" then
// just simplify it down to the first two in the list.
if (cleanedVal.length > 2) {
const hasAAndB =
cleanedVal.some((i) => i.name.startsWith("A")) &&
cleanedVal.some((i) => i.name.startsWith("B"));
if (!hasAAndB) {
cleanedVal = [cleanedVal[0], cleanedVal[1]];
}
}
let selected = cleanedVal.filter((d) => d.selected);
// None are selected.
if (!selected.length && cleanedVal.length) {
cleanedVal[0]!.selected = true;
}
selected = cleanedVal.filter((d) => d.selected);
if (selected.length === 1 && cleanedVal.length > 1) {
cleanedVal.find((d) => !d.selected)!.selected = true;
}
this._value.images = cleanedVal;
selected = cleanedVal.filter((d) => d.selected);
this.setSelected(selected as [ComfyImageData, ComfyImageData]);
}
get value() {
return this._value;
}
setSelected(selected: [ComfyImageData, ComfyImageData]) {
this._value.images.forEach((d) => (d.selected = false));
this.node.imgs.length = 0;
for (const sel of selected) {
if (!sel.img) {
sel.img = new Image();
sel.img.src = sel.url;
this.node.imgs.push(sel.img);
}
sel.selected = true;
}
this.selected = selected;
}
draw(ctx: CanvasRenderingContext2D, node: RgthreeImageComparer, width: number, y: number) {
this.hitAreas = {};
if (this.value.images.length > 2) {
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.font = `14px Arial`;
// Let's calculate the widths of all the labels.
const drawData: any = [];
const spacing = 5;
let x = 0;
for (const img of this.value.images) {
const width = measureText(ctx, img.name);
drawData.push({
img,
text: img.name,
x,
width: measureText(ctx, img.name),
});
x += width + spacing;
}
x = (node.size[0] - (x - spacing)) / 2;
for (const d of drawData) {
ctx.fillStyle = d.img.selected ? "rgba(180, 180, 180, 1)" : "rgba(180, 180, 180, 0.5)";
ctx.fillText(d.text, x, y);
this.hitAreas[d.text] = {
bounds: [x, y, d.width, 14],
data: d.img,
onDown: this.onSelectionDown,
};
x += d.width + spacing;
}
y += 20;
}
if (node.properties?.["comparer_mode"] === "Click") {
this.drawImage(ctx, this.selected[this.node.isPointerDown ? 1 : 0], y);
} else {
this.drawImage(ctx, this.selected[0], y);
if (node.isPointerOver) {
this.drawImage(ctx, this.selected[1], y, this.node.pointerOverPos[0]);
}
}
}
private onSelectionDown(
event: CanvasMouseEvent,
pos: Vector2,
node: LGraphNode,
bounds?: RgthreeBaseWidgetBounds,
) {
const selected = [...this.selected];
if (bounds?.data.name.startsWith("A")) {
selected[0] = bounds.data;
} else if (bounds?.data.name.startsWith("B")) {
selected[1] = bounds.data;
}
this.setSelected(selected as [ComfyImageData, ComfyImageData]);
}
private drawImage(
ctx: CanvasRenderingContext2D,
image: ComfyImageData | undefined,
y: number,
cropX?: number,
) {
if (!image?.img?.naturalWidth || !image?.img?.naturalHeight) {
return;
}
let [nodeWidth, nodeHeight] = this.node.size as [number, number];
const imageAspect = image?.img.naturalWidth / image?.img.naturalHeight;
let height = nodeHeight - y;
const widgetAspect = nodeWidth / height;
let targetWidth, targetHeight;
let offsetX = 0;
if (imageAspect > widgetAspect) {
targetWidth = nodeWidth;
targetHeight = nodeWidth / imageAspect;
} else {
targetHeight = height;
targetWidth = height * imageAspect;
offsetX = (nodeWidth - targetWidth) / 2;
}
const widthMultiplier = image?.img.naturalWidth / targetWidth;
const sourceX = 0;
const sourceY = 0;
const sourceWidth =
cropX != null ? (cropX - offsetX) * widthMultiplier : image?.img.naturalWidth;
const sourceHeight = image?.img.naturalHeight;
const destX = (nodeWidth - targetWidth) / 2;
const destY = y + (height - targetHeight) / 2;
const destWidth = cropX != null ? cropX - offsetX : targetWidth;
const destHeight = targetHeight;
ctx.save();
ctx.beginPath();
let globalCompositeOperation = ctx.globalCompositeOperation;
if (cropX) {
ctx.rect(destX, destY, destWidth, destHeight);
ctx.clip();
}
ctx.drawImage(
image?.img,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX,
destY,
destWidth,
destHeight,
);
// Shows a label overlayed on the image. Not perfect, keeping commented out.
// ctx.globalCompositeOperation = "difference";
// ctx.fillStyle = "rgba(180, 180, 180, 1)";
// ctx.textAlign = "center";
// ctx.font = `32px Arial`;
// ctx.fillText(image.name, nodeWidth / 2, y + 32);
if (cropX != null && cropX >= (nodeWidth - targetWidth) / 2 && cropX <= targetWidth + offsetX) {
ctx.beginPath();
ctx.moveTo(cropX, destY);
ctx.lineTo(cropX, destY + destHeight);
ctx.globalCompositeOperation = "difference";
ctx.strokeStyle = "rgba(255,255,255, 1)";
ctx.stroke();
}
ctx.globalCompositeOperation = globalCompositeOperation;
ctx.restore();
}
computeSize(width: number): Vector2 {
return [width, 20];
}
override serializeValue(
node: LGraphNode,
index: number,
): RgthreeImageComparerWidgetValue | Promise<RgthreeImageComparerWidgetValue> {
const v = [];
for (const data of this._value.images) {
// Remove the img since it can't serialize.
const d = {...data};
delete d.img;
v.push(d);
}
return {images: v};
}
}
app.registerExtension({
name: "rgthree.ImageComparer",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === RgthreeImageComparer.type) {
RgthreeImageComparer.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,70 @@
import type {LGraph, LGraphNodeConstructor, ISerialisedNode} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
class ImageInsetCrop extends RgthreeBaseServerNode {
static override title = NodeTypesString.IMAGE_INSET_CROP;
static override type = NodeTypesString.IMAGE_INSET_CROP;
static comfyClass = NodeTypesString.IMAGE_INSET_CROP;
static override exposedActions = ["Reset Crop"];
static maxResolution = 8192;
constructor(title = ImageInsetCrop.title) {
super(title);
}
override onAdded(graph: LGraph): void {
const measurementWidget = this.widgets[0]!;
let callback = measurementWidget.callback;
measurementWidget.callback = (...args) => {
this.setWidgetStep();
callback && callback.apply(measurementWidget, [...args]);
};
this.setWidgetStep();
}
override configure(info: ISerialisedNode): void {
super.configure(info);
this.setWidgetStep();
}
private setWidgetStep() {
const measurementWidget = this.widgets[0]!;
for (let i = 1; i <= 4; i++) {
if (measurementWidget.value === "Pixels") {
this.widgets[i]!.options.step = 80;
this.widgets[i]!.options.max = ImageInsetCrop.maxResolution;
} else {
this.widgets[i]!.options.step = 10;
this.widgets[i]!.options.max = 99;
}
}
}
override async handleAction(action: string): Promise<void> {
if (action === "Reset Crop") {
for (const widget of this.widgets) {
if (["left", "right", "top", "bottom"].includes(widget.name!)) {
widget.value = 0;
}
}
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ImageInsetCrop);
}
}
app.registerExtension({
name: "rgthree.ImageInsetCrop",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NodeTypesString.IMAGE_INSET_CROP) {
ImageInsetCrop.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,51 @@
import type {ISerialisedNode} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy";
import {app} from "scripts/app.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
class RgthreeImageOrLatentSize extends RgthreeBaseServerNode {
static override title = NodeTypesString.IMAGE_OR_LATENT_SIZE;
static override type = NodeTypesString.IMAGE_OR_LATENT_SIZE;
static comfyClass = NodeTypesString.IMAGE_OR_LATENT_SIZE;
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
}
constructor(title = NODE_CLASS.title) {
super(title);
}
override onNodeCreated() {
super.onNodeCreated?.();
// Litegraph uses an array of acceptable input types, even though ComfyUI's types don't type
// it out that way.
this.addInput("input", ["IMAGE", "LATENT", "MASK"] as any);
}
override configure(info: ISerialisedNode): void {
super.configure(info);
if (this.inputs?.length) {
// Litegraph uses an array of acceptable input types, even though ComfyUI's types don't type
// it out that way.
this.inputs[0]!.type = ["IMAGE", "LATENT", "MASK"] as any;
}
}
}
/** An uniformed name reference to the node class. */
const NODE_CLASS = RgthreeImageOrLatentSize;
/** Register the node. */
app.registerExtension({
name: "rgthree.ImageOrLatentSize",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NODE_CLASS.type) {
NODE_CLASS.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,229 @@
import type {
LGraphCanvas as TLGraphCanvas,
LGraphNode,
Vector2,
CanvasMouseEvent,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {rgthree} from "./rgthree.js";
/**
* A label node that allows you to put floating text anywhere on the graph. The text is the `Title`
* and the font size, family, color, alignment as well as a background color, padding, and
* background border radius can all be adjusted in the properties. Multiline text can be added from
* the properties panel (because ComfyUI let's you shift + enter there, only).
*/
export class Label extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.LABEL;
static override title = NodeTypesString.LABEL;
override comfyClass = NodeTypesString.LABEL;
static readonly title_mode = LiteGraph.NO_TITLE;
static collapsable = false;
static "@fontSize" = {type: "number"};
static "@fontFamily" = {type: "string"};
static "@fontColor" = {type: "string"};
static "@textAlign" = {type: "combo", values: ["left", "center", "right"]};
static "@backgroundColor" = {type: "string"};
static "@padding" = {type: "number"};
static "@borderRadius" = {type: "number"};
static "@angle" = {type: "number"};
override properties!: RgthreeBaseVirtualNode["properties"] & {
fontSize: number;
fontFamily: string;
fontColor: string;
textAlign: string;
backgroundColor: string;
padding: number;
borderRadius: number;
angle: number;
};
override resizable = false;
constructor(title = Label.title) {
super(title);
this.properties["fontSize"] = 12;
this.properties["fontFamily"] = "Arial";
this.properties["fontColor"] = "#ffffff";
this.properties["textAlign"] = "left";
this.properties["backgroundColor"] = "transparent";
this.properties["padding"] = 0;
this.properties["borderRadius"] = 0;
this.properties["angle"] = 0;
this.color = "#fff0";
this.bgcolor = "#fff0";
this.onConstructed();
}
draw(ctx: CanvasRenderingContext2D) {
this.flags = this.flags || {};
this.flags.allow_interaction = !this.flags.pinned;
ctx.save();
this.color = "#fff0";
this.bgcolor = "#fff0";
const fontColor = this.properties["fontColor"] || "#ffffff";
const backgroundColor = this.properties["backgroundColor"] || "";
ctx.font = `${Math.max(this.properties["fontSize"] || 0, 1)}px ${
this.properties["fontFamily"] ?? "Arial"
}`;
const padding = Number(this.properties["padding"]) ?? 0;
// Support literal "\\n" sequences as newlines and trim trailing newlines
const processedTitle = (this.title ?? "").replace(/\\n/g, "\n").replace(/\n*$/, "");
const lines = processedTitle.split("\n");
const maxWidth = Math.max(...lines.map((s) => ctx.measureText(s).width));
this.size[0] = maxWidth + padding * 2;
this.size[1] = this.properties["fontSize"] * lines.length + padding * 2;
// Apply rotation around the center, if angle provided
const angleDeg = parseInt(String(this.properties["angle"] ?? 0)) || 0;
if (angleDeg) {
const cx = this.size[0] / 2;
const cy = this.size[1] / 2;
ctx.translate(cx, cy);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
if (backgroundColor) {
ctx.beginPath();
const borderRadius = Number(this.properties["borderRadius"]) || 0;
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
ctx.fillStyle = backgroundColor;
ctx.fill();
}
ctx.textAlign = "left";
let textX = padding;
if (this.properties["textAlign"] === "center") {
ctx.textAlign = "center";
textX = this.size[0] / 2;
} else if (this.properties["textAlign"] === "right") {
ctx.textAlign = "right";
textX = this.size[0] - padding;
}
ctx.textBaseline = "top";
ctx.fillStyle = fontColor;
let currentY = padding;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i] || " ", textX, currentY);
currentY += this.properties["fontSize"];
}
ctx.restore();
}
override onDblClick(event: CanvasMouseEvent, pos: Vector2, canvas: TLGraphCanvas) {
// Since everything we can do here is in the properties, let's pop open the properties panel.
LGraphCanvas.active_canvas.showShowNodePanel(this);
}
override onShowCustomPanelInfo(panel: HTMLElement) {
panel.querySelector('div.property[data-property="Mode"]')?.remove();
panel.querySelector('div.property[data-property="Color"]')?.remove();
}
override inResizeCorner(x: number, y: number) {
// A little ridiculous there's both a resizable property and this method separately to draw the
// resize icon...
return this.resizable;
}
override getHelp() {
return `
<p>
The rgthree-comfy ${this.type!.replace("(rgthree)", "")} node allows you to add a floating
label to your workflow.
</p>
<p>
The text shown is the "Title" of the node and you can adjust the the font size, font family,
font color, text alignment as well as a background color, padding, and background border
radius from the node's properties. You can double-click the node to open the properties
panel.
<p>
<ul>
<li>
<p>
<strong>Pro tip #1:</strong> You can add multiline text from the properties panel
<i>(because ComfyUI let's you shift + enter there, only)</i>.
</p>
</li>
<li>
<p>
<strong>Pro tip #2:</strong> You can use ComfyUI's native "pin" option in the
right-click menu to make the label stick to the workflow and clicks to "go through".
You can right-click at any time to unpin.
</p>
</li>
<li>
<p>
<strong>Pro tip #3:</strong> Color values are hexidecimal strings, like "#FFFFFF" for
white, or "#660000" for dark red. You can supply a 7th & 8th value (or 5th if using
shorthand) to create a transluscent color. For instance, "#FFFFFF88" is semi-transparent
white.
</p>
</li>
</ul>`;
}
}
/**
* We override the drawNode to see if we're drawing our label and, if so, hijack it so we can draw
* it like we want. We also do call out to oldDrawNode, which takes care of very minimal things,
* like a select box.
*/
const oldDrawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function (node: LGraphNode, ctx: CanvasRenderingContext2D) {
if (node.constructor === Label.prototype.constructor) {
// These get set very aggressively; maybe an extension is doing it. We'll just clear them out
// each time.
(node as Label).bgcolor = "transparent";
(node as Label).color = "transparent";
const v = oldDrawNode.apply(this, arguments as any);
(node as Label).draw(ctx);
return v;
}
const v = oldDrawNode.apply(this, arguments as any);
return v;
};
/**
* We override LGraph getNodeOnPos to see if we're being called while also processing a mouse down
* and, if so, filter out any label nodes on labels that are pinned. This makes the click go
* "through" the label. We still allow right clicking (so you can unpin) and double click for the
* properties panel, though that takes two double clicks (one to select, one to actually double
* click).
*/
const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
LGraph.prototype.getNodeOnPos = function (x: number, y: number, nodes_list?: LGraphNode[]) {
if (
// processMouseDown always passes in the nodes_list
nodes_list &&
rgthree.processingMouseDown &&
rgthree.lastCanvasMouseEvent?.type.includes("down") &&
rgthree.lastCanvasMouseEvent?.which === 1
) {
// Using the same logic from LGraphCanvas processMouseDown, let's see if we consider this a
// double click.
let isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
if (!isDoubleClick) {
nodes_list = [...nodes_list].filter((n) => !(n instanceof Label) || !n.flags?.pinned);
}
}
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
};
// Register the extension.
app.registerExtension({
name: "rgthree.Label",
registerCustomNodes() {
Label.setUp();
},
});

View File

@@ -0,0 +1,156 @@
import type {
LGraphNode,
ContextMenu,
IContextMenuOptions,
IContextMenuValue,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {rgthree} from "./rgthree.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
const SPECIAL_ENTRIES = [/^(CHOOSE|NONE|DISABLE|OPEN)(\s|$)/i, /^\p{Extended_Pictographic}/gu];
/**
* Handles a large, flat list of string values given ContextMenu and breaks it up into subfolder, if
* they exist. This is experimental and initially built to work for CheckpointLoaderSimple.
*/
app.registerExtension({
name: "rgthree.ContextMenuAutoNest",
async setup() {
const logger = rgthree.newLogSession("[ContextMenuAutoNest]");
const existingContextMenu = LiteGraph.ContextMenu;
// @ts-ignore: TypeScript doesn't like this override.
LiteGraph.ContextMenu = function (
values: IContextMenuValue[],
options: IContextMenuOptions<string, {rgthree_doNotNest: boolean}>,
) {
const threshold = CONFIG_SERVICE.getConfigValue("features.menu_auto_nest.threshold", 20);
const enabled = CONFIG_SERVICE.getConfigValue("features.menu_auto_nest.subdirs", false);
// If we're not enabled, or are incompatible, then just call out safely.
let incompatible: string | boolean = !enabled || !!options?.extra?.rgthree_doNotNest;
if (!incompatible) {
if (values.length <= threshold) {
incompatible = `Skipping context menu auto nesting b/c threshold is not met (${threshold})`;
}
// If there's a rgthree_originalCallback, then we're nested and don't need to check things
// we only expect on the first nesting.
if (!(options.parentMenu?.options as any)?.rgthree_originalCallback) {
// On first context menu, we require a callback and a flat list of options as strings.
if (!options?.callback) {
incompatible = `Skipping context menu auto nesting b/c a callback was expected.`;
} else if (values.some((i) => typeof i !== "string")) {
incompatible = `Skipping context menu auto nesting b/c not all values were strings.`;
}
}
}
if (incompatible) {
if (enabled) {
const [n, v] = logger.infoParts(
"Skipping context menu auto nesting for incompatible menu.",
);
console[n]?.(...v);
}
return existingContextMenu.apply(this as any, [...arguments] as any);
}
const folders: {[key: string]: IContextMenuValue[]} = {};
const specialOps: IContextMenuValue[] = [];
const folderless: IContextMenuValue[] = [];
for (const value of values) {
if (!value) {
folderless.push(value);
continue;
}
const newValue = typeof value === "string" ? {content: value} : Object.assign({}, value);
(newValue as any).rgthree_originalValue = (value as any).rgthree_originalValue || value;
const valueContent = newValue.content || "";
const splitBy = valueContent.indexOf("/") > -1 ? "/" : "\\";
const valueSplit = valueContent.split(splitBy);
if (valueSplit.length > 1) {
const key = valueSplit.shift()!;
newValue.content = valueSplit.join(splitBy);
folders[key] = folders[key] || [];
folders[key]!.push(newValue);
} else if (SPECIAL_ENTRIES.some((r) => r.test(valueContent))) {
specialOps.push(newValue);
} else {
folderless.push(newValue);
}
}
const foldersCount = Object.values(folders).length;
if (foldersCount > 0) {
// Propogate the original callback down through the options.
(options as any).rgthree_originalCallback =
(options as any).rgthree_originalCallback ||
(options.parentMenu?.options as any)?.rgthree_originalCallback ||
options.callback;
const oldCallback = (options as any)?.rgthree_originalCallback;
options.callback = undefined;
const newCallback = (
item: IContextMenuValue,
options: IContextMenuOptions,
event: MouseEvent,
parentMenu: ContextMenu | undefined,
node: LGraphNode,
) => {
oldCallback?.((item as any)?.rgthree_originalValue!, options, event, undefined, node);
};
const [n, v] = logger.infoParts(`Nested folders found (${foldersCount}).`);
console[n]?.(...v);
const newValues: IContextMenuValue[] = [];
for (const [folderName, folderValues] of Object.entries(folders)) {
newValues.push({
content: `📁 ${folderName}`,
has_submenu: true,
callback: () => {
/* no-op, use the item callback. */
},
submenu: {
options: folderValues.map((value) => {
value!.callback = newCallback;
return value;
}),
},
});
}
values = ([] as IContextMenuValue[]).concat(
specialOps.map((f) => {
if (typeof f === "string") {
f = {content: f};
}
f!.callback = newCallback;
return f;
}),
newValues,
folderless.map((f) => {
if (typeof f === "string") {
f = {content: f};
}
f!.callback = newCallback;
return f;
}),
);
}
if (options.scale == null) {
options.scale = Math.max(app.canvas.ds?.scale || 1, 1);
}
const oldCtrResponse = existingContextMenu.call(this as any, values, options as any);
// For some reason, LiteGraph calls submenus with "this.constructor" which no longer allows
// us to continue building deep nesting, as well as skips many other extensions (even
// ComfyUI's core extensions like translations) from working on submenus. It also removes
// search, etc. While this is a recent-ish issue, I can't seem to find the culpit as it looks
// like old litegraph always did this. Perhaps changing it to a Class? Anyway, this fixes it;
// Hopefully without issues.
if ((oldCtrResponse as any)?.constructor) {
(oldCtrResponse as any).constructor = LiteGraph.ContextMenu;
}
return this;
};
},
});

View File

@@ -0,0 +1,75 @@
import type {IContextMenuValue, LGraphCanvas} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
const clipboardSupportedPromise = new Promise<boolean>(async (resolve) => {
try {
// MDN says to check this, but it doesn't work in Mozilla... however, in secure contexts
// (localhost included), it's given by default if the user has it flagged.. so we should be
// able to check in the latter ClipboardItem too.
const result = await navigator.permissions.query({name: "clipboard-write"} as any);
resolve(result.state === "granted");
return;
} catch (e) {
try {
if (!navigator.clipboard.write) {
throw new Error();
}
new ClipboardItem({"image/png": new Blob([], {type: "image/png"})});
resolve(true);
return;
} catch (e) {
resolve(false);
}
}
});
/**
* Adds a "Copy Image" to images in similar fashion to the "native" Open Image and Save Image
* options.
*/
app.registerExtension({
name: "rgthree.CopyImageToClipboard",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name.toLowerCase().includes("image")) {
if (await clipboardSupportedPromise) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] {
options = getExtraMenuOptions?.call(this, canvas, options) ?? options;
// If we already have a copy image somehow, then let's skip ours.
if (this.imgs?.length) {
let img =
this.imgs[this.imageIndex || 0] || this.imgs[this.overIndex || 0] || this.imgs[0];
const foundIdx = options.findIndex((option) => option?.content?.includes("Copy Image"));
if (img && foundIdx === -1) {
const menuItem: IContextMenuValue = {
content: "Copy Image (rgthree)",
callback: () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
canvas.toBlob((blob) => {
navigator.clipboard.write([new ClipboardItem({"image/png": blob!})]);
});
},
};
let idx = options.findIndex((option) => option?.content?.includes("Open Image")) + 1;
if (idx != null) {
options.splice(idx, 0, menuItem);
} else {
options.unshift(menuItem);
}
}
}
return [];
};
}
}
},
});

View File

@@ -0,0 +1,93 @@
import type {
IContextMenuValue,
LGraphCanvas as TLGraphCanvas,
LGraphNode,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {rgthree} from "./rgthree.js";
import {getGroupNodes, getOutputNodes} from "./utils.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
function showQueueNodesMenuIfOutputNodesAreSelected(
existingOptions: (IContextMenuValue<unknown> | null)[],
) {
if (CONFIG_SERVICE.getConfigValue("features.menu_queue_selected_nodes") === false) {
return;
}
const outputNodes = getOutputNodes(Object.values(app.canvas.selected_nodes));
const menuItem = {
content: `Queue Selected Output Nodes (rgthree) &nbsp;`,
className: "rgthree-contextmenu-item",
callback: () => {
rgthree.queueOutputNodes(outputNodes);
},
disabled: !outputNodes.length,
};
let idx = existingOptions.findIndex((o) => o?.content === "Outputs") + 1;
idx = idx || existingOptions.findIndex((o) => o?.content === "Align") + 1;
idx = idx || 3;
existingOptions.splice(idx, 0, menuItem);
}
function showQueueGroupNodesMenuIfGroupIsSelected(
existingOptions: (IContextMenuValue<unknown> | null)[],
) {
if (CONFIG_SERVICE.getConfigValue("features.menu_queue_selected_nodes") === false) {
return;
}
const group =
rgthree.lastCanvasMouseEvent &&
(app.canvas.getCurrentGraph() || app.graph).getGroupOnPos(
rgthree.lastCanvasMouseEvent.canvasX,
rgthree.lastCanvasMouseEvent.canvasY,
);
const outputNodes: LGraphNode[] | null = (group && getOutputNodes(getGroupNodes(group))) || null;
const menuItem = {
content: `Queue Group Output Nodes (rgthree) &nbsp;`,
className: "rgthree-contextmenu-item",
callback: () => {
outputNodes && rgthree.queueOutputNodes(outputNodes);
},
disabled: !outputNodes?.length,
};
let idx = existingOptions.findIndex((o) => o?.content?.startsWith("Queue Selected ")) + 1;
idx = idx || existingOptions.findIndex((o) => o?.content === "Outputs") + 1;
idx = idx || existingOptions.findIndex((o) => o?.content === "Align") + 1;
idx = idx || 3;
existingOptions.splice(idx, 0, menuItem);
}
/**
* Adds a "Queue Node" menu item to all output nodes, working with `rgthree.queueOutputNode` to
* execute only a single node's path.
*/
app.registerExtension({
name: "rgthree.QueueNode",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (
canvas: TLGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] {
const extraOptions = getExtraMenuOptions?.call(this, canvas, options) ?? [];
showQueueNodesMenuIfOutputNodesAreSelected(options);
showQueueGroupNodesMenuIfGroupIsSelected(options);
return extraOptions;
};
},
async setup() {
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const options = getCanvasMenuOptions.apply(this, [...args] as any);
showQueueNodesMenuIfOutputNodesAreSelected(options);
showQueueGroupNodesMenuIfGroupIsSelected(options);
return options;
};
},
});

View File

@@ -0,0 +1,51 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseNodeModeChanger} from "./base_node_mode_changer.js";
import {NodeTypesString} from "./constants.js";
const MODE_MUTE = 2;
const MODE_ALWAYS = 0;
class MuterNode extends BaseNodeModeChanger {
static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
static override type = NodeTypesString.FAST_MUTER;
static override title = NodeTypesString.FAST_MUTER;
override comfyClass = NodeTypesString.FAST_MUTER;
override readonly modeOn = MODE_ALWAYS;
override readonly modeOff = MODE_MUTE;
constructor(title = MuterNode.title) {
super(title);
this.onConstructed();
}
override async handleAction(action: string) {
if (action === "Mute all") {
for (const widget of this.widgets) {
this.forceWidgetOff(widget, true);
}
} else if (action === "Enable all") {
for (const widget of this.widgets) {
this.forceWidgetOn(widget, true);
}
} else if (action === "Toggle all") {
for (const widget of this.widgets) {
this.forceWidgetToggle(widget, true);
}
}
}
}
app.registerExtension({
name: "rgthree.Muter",
registerCustomNodes() {
MuterNode.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == MuterNode.title) {
(node as any)._tempWidth = node.size[0];
}
},
});

View File

@@ -0,0 +1,168 @@
import type {
LLink,
LGraph,
LGraphCanvas,
LGraphNode as TLGraphNode,
IContextMenuOptions,
ContextMenu,
IContextMenuValue,
Size,
ISerialisedNode,
Point,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {wait} from "rgthree/common/shared_utils.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {BaseCollectorNode} from "./base_node_collector.js";
import {NodeTypesString} from "./constants.js";
/**
* The Collector Node. Takes any number of inputs as connections for nodes and collects them into
* one outputs. The next node will decide what to do with them.
*
* Currently only works with the Fast Muter, Fast Bypasser, and Fast Actions Button.
*/
class CollectorNode extends BaseCollectorNode {
static override type = NodeTypesString.NODE_COLLECTOR;
static override title = NodeTypesString.NODE_COLLECTOR;
override comfyClass = NodeTypesString.NODE_COLLECTOR;
constructor(title = CollectorNode.title) {
super(title);
this.onConstructed();
}
override onConstructed(): boolean {
this.addOutput("Output", "*");
return super.onConstructed();
}
}
/** Legacy "Combiner" */
class CombinerNode extends CollectorNode {
static legacyType = "Node Combiner (rgthree)";
static override title = "‼️ Node Combiner [DEPRECATED]";
constructor(title = CombinerNode.title) {
super(title);
const note = ComfyWidgets["STRING"](
this,
"last_seed",
["STRING", {multiline: true}],
app,
).widget;
note.inputEl!.value =
'The Node Combiner has been renamed to Node Collector. You can right-click and select "Update to Node Collector" to attempt to automatically update.';
note.inputEl!.readOnly = true;
note.inputEl!.style.backgroundColor = "#332222";
note.inputEl!.style.fontWeight = "bold";
note.inputEl!.style.fontStyle = "italic";
note.inputEl!.style.opacity = "0.8";
this.getExtraMenuOptions = (
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] => {
options.splice(options.length - 1, 0, {
content: "‼️ Update to Node Collector",
callback: (
_value: IContextMenuValue,
_options: IContextMenuOptions,
_event: MouseEvent,
_parentMenu: ContextMenu | undefined,
_node: TLGraphNode,
) => {
updateCombinerToCollector(this);
},
});
return [];
};
}
override configure(info: ISerialisedNode) {
super.configure(info);
if (this.title != CombinerNode.title && !this.title.startsWith("‼️")) {
this.title = "‼️ " + this.title;
}
}
}
/**
* Updates a Node Combiner to a Node Collector.
*/
async function updateCombinerToCollector(node: TLGraphNode) {
if (node.type === CombinerNode.legacyType) {
// Create a new CollectorNode.
const newNode = new CollectorNode();
if (node.title != CombinerNode.title) {
newNode.title = node.title.replace("‼️ ", "");
}
// Port the position, size, and properties from the old node.
newNode.pos = [...node.pos] as Point;
newNode.size = [...node.size] as Size;
newNode.properties = {...node.properties};
// We now collect the links data, inputs and outputs, of the old node since these will be
// lost when we remove it.
const links: any[] = [];
const graph = (node.graph || app.graph);
for (const [index, output] of node.outputs.entries()) {
for (const linkId of output.links || []) {
const link: LLink = graph.links[linkId]!;
if (!link) continue;
const targetNode = graph.getNodeById(link.target_id);
links.push({node: newNode, slot: index, targetNode, targetSlot: link.target_slot});
}
}
for (const [index, input] of node.inputs.entries()) {
const linkId = input.link;
if (linkId) {
const link: LLink = graph.links[linkId]!;
const originNode = graph.getNodeById(link.origin_id);
links.push({
node: originNode,
slot: link.origin_slot,
targetNode: newNode,
targetSlot: index,
});
}
}
// Add the new node, remove the old node.
graph.add(newNode);
await wait();
// Now go through and connect the other nodes up as they were.
for (const link of links) {
link.node.connect(link.slot, link.targetNode, link.targetSlot);
}
await wait();
graph.remove(node);
}
}
app.registerExtension({
name: "rgthree.NodeCollector",
registerCustomNodes() {
addConnectionLayoutSupport(CollectorNode, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(CollectorNode.title, CollectorNode);
CollectorNode.category = CollectorNode._category;
},
});
app.registerExtension({
name: "rgthree.NodeCombiner",
registerCustomNodes() {
addConnectionLayoutSupport(CombinerNode, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(CombinerNode.legacyType, CombinerNode);
CombinerNode.category = CombinerNode._category;
},
});

View File

@@ -0,0 +1,280 @@
import type {
INodeInputSlot,
INodeOutputSlot,
LGraphCanvas,
LGraphEventMode,
LGraphNode,
LLink,
Vector2,
ISerialisedNode,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {
PassThroughFollowing,
addConnectionLayoutSupport,
changeModeOfNodes,
getConnectedInputNodesAndFilterPassThroughs,
getConnectedOutputNodesAndFilterPassThroughs,
} from "./utils.js";
import {wait} from "rgthree/common/shared_utils.js";
import {BaseCollectorNode} from "./base_node_collector.js";
import {NodeTypesString, stripRgthree} from "./constants.js";
import {fitString} from "./utils_canvas.js";
import {rgthree} from "./rgthree.js";
const MODE_ALWAYS = 0;
const MODE_MUTE = 2;
const MODE_BYPASS = 4;
const MODE_REPEATS = [MODE_MUTE, MODE_BYPASS];
const MODE_NOTHING = -99; // MADE THIS UP.
const MODE_TO_OPTION = new Map([
[MODE_ALWAYS, "ACTIVE"],
[MODE_MUTE, "MUTE"],
[MODE_BYPASS, "BYPASS"],
[MODE_NOTHING, "NOTHING"],
]);
const OPTION_TO_MODE = new Map([
["ACTIVE", MODE_ALWAYS],
["MUTE", MODE_MUTE],
["BYPASS", MODE_BYPASS],
["NOTHING", MODE_NOTHING],
]);
const MODE_TO_PROPERTY = new Map([
[MODE_MUTE, "on_muted_inputs"],
[MODE_BYPASS, "on_bypassed_inputs"],
[MODE_ALWAYS, "on_any_active_inputs"],
]);
const logger = rgthree.newLogSession("[NodeModeRelay]");
/**
* Like a BaseCollectorNode, this relay node connects to a Repeater node and _relays_ mode changes
* changes to the repeater (so it can go on to modify its connections).
*/
class NodeModeRelay extends BaseCollectorNode {
override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
static override type = NodeTypesString.NODE_MODE_RELAY;
static override title = NodeTypesString.NODE_MODE_RELAY;
override comfyClass = NodeTypesString.NODE_MODE_RELAY;
static "@on_muted_inputs" = {
type: "combo",
values: ["MUTE", "ACTIVE", "BYPASS", "NOTHING"],
};
static "@on_bypassed_inputs" = {
type: "combo",
values: ["BYPASS", "ACTIVE", "MUTE", "NOTHING"],
};
static "@on_any_active_inputs" = {
type: "combo",
values: ["BYPASS", "ACTIVE", "MUTE", "NOTHING"],
};
constructor(title?: string) {
super(title);
this.properties["on_muted_inputs"] = "MUTE";
this.properties["on_bypassed_inputs"] = "BYPASS";
this.properties["on_any_active_inputs"] = "ACTIVE";
this.onConstructed();
}
override onConstructed() {
this.addOutput("REPEATER", "_NODE_REPEATER_", {
color_on: "#Fc0",
color_off: "#a80",
shape: LiteGraph.ARROW_SHAPE,
});
setTimeout(() => {
this.stabilize();
}, 500);
return super.onConstructed();
}
override onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
super.onModeChange(from, to);
// If we aren't connected to anything, then we'll use our mode to relay when it changes.
if (this.inputs.length <= 1 && !this.isInputConnected(0) && this.isAnyOutputConnected()) {
const [n, v] = logger.infoParts(`Mode change without any inputs; relaying our mode.`);
console[n]?.(...v);
// Pass "to" since there may be other getters in the way to access this.mode directly.
this.dispatchModeToRepeater(to);
}
}
override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: LGraphCanvas): void {
if (this.flags?.collapsed) {
return;
}
if (
this.properties["on_muted_inputs"] !== "MUTE" ||
this.properties["on_bypassed_inputs"] !== "BYPASS" ||
this.properties["on_any_active_inputs"] != "ACTIVE"
) {
let margin = 15;
ctx.textAlign = "left";
let label = `*(MUTE > ${this.properties["on_muted_inputs"]}, `;
label += `BYPASS > ${this.properties["on_bypassed_inputs"]}, `;
label += `ACTIVE > ${this.properties["on_any_active_inputs"]})`;
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
const oldFont = ctx.font;
ctx.font = "italic " + (LiteGraph.NODE_SUBTEXT_SIZE - 2) + "px Arial";
ctx.fillText(fitString(ctx, label, this.size[0] - 20), 15, this.size[1] - 6);
ctx.font = oldFont;
}
}
override computeSize(out: Vector2) {
let size = super.computeSize(out);
if (
this.properties["on_muted_inputs"] !== "MUTE" ||
this.properties["on_bypassed_inputs"] !== "BYPASS" ||
this.properties["on_any_active_inputs"] != "ACTIVE"
) {
size[1] += 17;
}
return size;
}
override onConnectOutput(
outputIndex: number,
inputType: string | -1,
inputSlot: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number,
): boolean {
let canConnect = super.onConnectOutput?.(
outputIndex,
inputType,
inputSlot,
inputNode,
inputIndex,
);
let nextNode = getConnectedOutputNodesAndFilterPassThroughs(this, inputNode)[0] ?? inputNode;
return canConnect && nextNode.type === NodeTypesString.NODE_MODE_REPEATER;
}
override onConnectionsChange(
type: number,
slotIndex: number,
isConnected: boolean,
link_info: LLink,
ioSlot: INodeOutputSlot | INodeInputSlot,
): void {
super.onConnectionsChange(type, slotIndex, isConnected, link_info, ioSlot);
setTimeout(() => {
this.stabilize();
}, 500);
}
stabilize() {
// If we aren't connected to a repeater, then theres no sense in checking. And if we are, but
// have no inputs, then we're also not ready.
if (!this.graph || !this.isAnyOutputConnected() || !this.isInputConnected(0)) {
return;
}
const inputNodes = getConnectedInputNodesAndFilterPassThroughs(
this,
this,
-1,
this.inputsPassThroughFollowing,
);
let mode: LGraphEventMode | -99 | undefined = undefined;
for (const inputNode of inputNodes) {
// If we haven't set our mode to be, then let's set it. Otherwise, mode will stick if it
// remains constant, otherwise, if we hit an ALWAYS, then we'll unmute all repeaters and
// if not then we won't do anything.
if (mode === undefined) {
mode = inputNode.mode;
} else if (mode === inputNode.mode && MODE_REPEATS.includes(mode)) {
continue;
} else if (inputNode.mode === MODE_ALWAYS || mode === MODE_ALWAYS) {
mode = MODE_ALWAYS;
} else {
mode = undefined;
}
}
this.dispatchModeToRepeater(mode);
setTimeout(() => {
this.stabilize();
}, 500);
}
/**
* Sends the mode to the repeater, checking to see if we're modifying our mode.
*/
private dispatchModeToRepeater(mode?: LGraphEventMode | -99 | null) {
if (mode != null) {
const propertyVal = this.properties?.[MODE_TO_PROPERTY.get(mode) || ""];
const newMode = OPTION_TO_MODE.get(propertyVal as string);
mode = (newMode !== null ? newMode : mode) as LGraphEventMode | -99;
if (mode !== null && mode !== MODE_NOTHING) {
if (this.outputs?.length) {
const outputNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
for (const outputNode of outputNodes) {
changeModeOfNodes(outputNode, mode);
wait(16).then(() => {
outputNode.setDirtyCanvas(true, true);
});
}
}
}
}
}
override getHelp() {
return `
<p>
This node will relay its input nodes' modes (Mute, Bypass, or Active) to a connected
${stripRgthree(NodeTypesString.NODE_MODE_REPEATER)} (which would then repeat that mode
change to all of its inputs).
</p>
<ul>
<li><p>
When all connected input nodes are muted, the relay will set a connected repeater to
mute (by default).
</p></li>
<li><p>
When all connected input nodes are bypassed, the relay will set a connected repeater to
bypass (by default).
</p></li>
<li><p>
When any connected input nodes are active, the relay will set a connected repeater to
active (by default).
</p></li>
<li><p>
If no inputs are connected, the relay will set a connected repeater to its mode <i>when
its own mode is changed</i>. <b>Note</b>, if any inputs are connected, then the above
will occur and the Relay's mode does not matter.
</p></li>
</ul>
<p>
Note, you can change which signals get sent on the above in the <code>Properties</code>.
For instance, you could configure an inverse relay which will send a MUTE when any of its
inputs are active (instead of sending an ACTIVE signal), and send an ACTIVE signal when all
of its inputs are muted (instead of sending a MUTE signal), etc.
</p>
`;
}
}
app.registerExtension({
name: "rgthree.NodeModeRepeaterHelper",
registerCustomNodes() {
addConnectionLayoutSupport(NodeModeRelay, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(NodeModeRelay.type, NodeModeRelay);
NodeModeRelay.category = NodeModeRelay._category;
},
});

View File

@@ -0,0 +1,216 @@
import type {
INodeInputSlot,
INodeOutputSlot,
LGraphEventMode,
LGraphGroup,
LGraphNode,
LLink,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseCollectorNode} from "./base_node_collector.js";
import {NodeTypesString, stripRgthree} from "./constants.js";
import {
PassThroughFollowing,
addConnectionLayoutSupport,
changeModeOfNodes,
getConnectedInputNodesAndFilterPassThroughs,
getConnectedOutputNodesAndFilterPassThroughs,
getGroupNodes,
} from "./utils.js";
class NodeModeRepeater extends BaseCollectorNode {
override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
static override type = NodeTypesString.NODE_MODE_REPEATER;
static override title = NodeTypesString.NODE_MODE_REPEATER;
override comfyClass = NodeTypesString.NODE_MODE_REPEATER;
private hasRelayInput = false;
private hasTogglerOutput = false;
constructor(title?: string) {
super(title);
this.onConstructed();
}
override onConstructed(): boolean {
this.addOutput("OPT_CONNECTION", "*", {
color_on: "#Fc0",
color_off: "#a80",
});
return super.onConstructed();
}
override onConnectOutput(
outputIndex: number,
inputType: string | -1,
inputSlot: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number,
): boolean {
// We can only connect to a a FAST_MUTER or FAST_BYPASSER if we aren't connectged to a relay, since the relay wins.
let canConnect = !this.hasRelayInput;
canConnect =
canConnect && super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
// Output can only connect to a FAST MUTER, FAST BYPASSER, NODE_COLLECTOR OR ACTION BUTTON
let nextNode = getConnectedOutputNodesAndFilterPassThroughs(this, inputNode)[0] || inputNode;
return (
canConnect &&
[
NodeTypesString.FAST_MUTER,
NodeTypesString.FAST_BYPASSER,
NodeTypesString.NODE_COLLECTOR,
NodeTypesString.FAST_ACTIONS_BUTTON,
NodeTypesString.REROUTE,
NodeTypesString.RANDOM_UNMUTER,
].includes(nextNode.type || "")
);
}
override onConnectInput(
inputIndex: number,
outputType: string | -1,
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
): boolean {
// We can only connect to a a FAST_MUTER or FAST_BYPASSER if we aren't connectged to a relay, since the relay wins.
let canConnect = super.onConnectInput?.(
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
);
// Output can only connect to a FAST MUTER or FAST BYPASSER
let nextNode = getConnectedOutputNodesAndFilterPassThroughs(this, outputNode)[0] || outputNode;
const isNextNodeRelay = nextNode.type === NodeTypesString.NODE_MODE_RELAY;
return canConnect && (!isNextNodeRelay || !this.hasTogglerOutput);
}
override onConnectionsChange(
type: number,
slotIndex: number,
isConnected: boolean,
linkInfo: LLink,
ioSlot: INodeOutputSlot | INodeInputSlot,
): void {
super.onConnectionsChange(type, slotIndex, isConnected, linkInfo, ioSlot);
let hasTogglerOutput = false;
let hasRelayInput = false;
const outputNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
for (const outputNode of outputNodes) {
if (
outputNode?.type === NodeTypesString.FAST_MUTER ||
outputNode?.type === NodeTypesString.FAST_BYPASSER
) {
hasTogglerOutput = true;
break;
}
}
const inputNodes = getConnectedInputNodesAndFilterPassThroughs(this);
for (const [index, inputNode] of inputNodes.entries()) {
if (inputNode?.type === NodeTypesString.NODE_MODE_RELAY) {
// We can't be connected to a relay if we're connected to a toggler. Something has gone wrong.
if (hasTogglerOutput) {
console.log(`Can't be connected to a Relay if also output to a toggler.`);
this.disconnectInput(index);
} else {
hasRelayInput = true;
if (this.inputs[index]) {
this.inputs[index]!.color_on = "#FC0";
this.inputs[index]!.color_off = "#a80";
}
}
} else {
changeModeOfNodes(inputNode, this.mode);
}
}
this.hasTogglerOutput = hasTogglerOutput;
this.hasRelayInput = hasRelayInput;
// If we have a relay input, then we should remove the toggler output, or add it if not.
if (this.hasRelayInput) {
if (this.outputs[0]) {
this.disconnectOutput(0);
this.removeOutput(0);
}
} else if (!this.outputs[0]) {
this.addOutput("OPT_CONNECTION", "*", {
color_on: "#Fc0",
color_off: "#a80",
});
}
}
/** When a mode change, we want all connected nodes to match except for connected relays. */
override onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
super.onModeChange(from, to);
const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this).filter(
(node) => node.type !== NodeTypesString.NODE_MODE_RELAY,
);
if (linkedNodes.length) {
for (const node of linkedNodes) {
if (node.type !== NodeTypesString.NODE_MODE_RELAY) {
// Use "to" as there may be other getters in the way to access this.mode directly.
changeModeOfNodes(node, to);
}
}
} else if (this.graph?._groups?.length) {
// No linked nodes.. check if we're in a group.
for (const group of this.graph._groups as LGraphGroup[]) {
group.recomputeInsideNodes();
const groupNodes = getGroupNodes(group);
if (groupNodes?.includes(this)) {
for (const node of groupNodes) {
if (node !== this) {
// Use "to" as there may be other getters in the way to access this.mode directly.
changeModeOfNodes(node, to);
}
}
}
}
}
}
override getHelp(): string {
return `
<p>
When this node's mode (Mute, Bypass, Active) changes, it will "repeat" that mode to all
connected input nodes, or, if there are no connected nodes AND it is overlapping a group,
"repeat" it's mode to all nodes in that group.
</p>
<ul>
<li><p>
Optionally, connect this mode's output to a ${stripRgthree(NodeTypesString.FAST_MUTER)}
or ${stripRgthree(NodeTypesString.FAST_BYPASSER)} for a single toggle to quickly
mute/bypass all its connected nodes.
</p></li>
<li><p>
Optionally, connect a ${stripRgthree(NodeTypesString.NODE_MODE_RELAY)} to this nodes
inputs to have it automatically toggle its mode. If connected, this will always take
precedence (and disconnect any connected fast togglers).
</p></li>
</ul>
`;
}
}
app.registerExtension({
name: "rgthree.NodeModeRepeater",
registerCustomNodes() {
addConnectionLayoutSupport(NodeModeRepeater, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(NodeModeRepeater.type, NodeModeRepeater);
NodeModeRepeater.category = NodeModeRepeater._category;
},
});

View File

@@ -0,0 +1,137 @@
import type {Parser, Node, Tree} from "web-tree-sitter";
import type {IStringWidget, IWidget} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {Exposed, execute, PyTuple} from "rgthree/common/py_parser.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {RgthreeBetterButtonWidget} from "./utils_widgets.js";
import {NodeTypesString} from "./constants.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import { changeModeOfNodes, getNodeById } from "./utils.js";
const BUILT_INS = {
node: {
fn: (query: string | number) => {
if (typeof query === "number" || /^\d+(\.\d+)?/.exec(query)) {
return new ComfyNodeWrapper(Number(query));
}
return null;
},
},
};
class RgthreePowerConductor extends RgthreeBaseVirtualNode {
static override title = NodeTypesString.POWER_CONDUCTOR;
static override type = NodeTypesString.POWER_CONDUCTOR;
override comfyClass = NodeTypesString.POWER_CONDUCTOR;
override serialize_widgets = true;
private codeWidget: IStringWidget;
private buttonWidget: RgthreeBetterButtonWidget;
constructor(title = RgthreePowerConductor.title) {
super(title);
this.codeWidget = ComfyWidgets.STRING(this, "", ["STRING", {multiline: true}], app).widget;
this.addCustomWidget(this.codeWidget);
(this.buttonWidget = new RgthreeBetterButtonWidget("Run", (...args: any[]) => {
this.execute();
})),
this.addCustomWidget(this.buttonWidget);
this.onConstructed();
}
private execute() {
execute(this.codeWidget.value, {}, BUILT_INS);
}
}
const NODE_CLASS = RgthreePowerConductor;
/**
* A wrapper around nodes to add helpers and control the exposure of properties and methods.
*/
class ComfyNodeWrapper {
#id: number;
constructor(id: number) {
this.#id = id;
}
private getNode() {
return getNodeById(this.#id)!;
}
@Exposed get id() {
return this.getNode().id;
}
@Exposed get title() {
return this.getNode().title;
}
set title(value: string) {
this.getNode().title = value;
}
@Exposed get widgets() {
return new PyTuple(this.getNode().widgets?.map((w) => new ComfyWidgetWrapper(w as IWidget)));
}
@Exposed get mode() {
return this.getNode().mode;
}
@Exposed mute() {
changeModeOfNodes(this.getNode(), 2);
}
@Exposed bypass() {
changeModeOfNodes(this.getNode(), 4);
}
@Exposed enable() {
changeModeOfNodes(this.getNode(), 0);
}
}
/**
* A wrapper around widgets to add helpers and control the exposure of properties and methods.
*/
class ComfyWidgetWrapper {
#widget: IWidget;
constructor(widget: IWidget) {
this.#widget = widget;
}
@Exposed get value() {
return this.#widget.value;
}
@Exposed get label() {
return this.#widget.label;
}
@Exposed toggle(value?: boolean) {
// IF the widget has a "toggle" method, then call it.
if (typeof (this.#widget as any)["toggle"] === "function") {
(this.#widget as any)["toggle"](value);
} else {
// Error.
}
}
}
/** Register the node. */
app.registerExtension({
name: "rgthree.PowerConductor",
registerCustomNodes() {
if (CONFIG_SERVICE.getConfigValue("unreleased.power_conductor.enabled")) {
NODE_CLASS.setUp();
}
},
});

View File

@@ -0,0 +1,851 @@
import type {
LGraphNode as TLGraphNode,
LGraphCanvas,
Vector2,
IContextMenuValue,
IFoundSlot,
CanvasMouseEvent,
ISerialisedNode,
ICustomWidget,
CanvasPointerEvent,
} from "@comfyorg/frontend";
import type {ComfyApiFormat, ComfyNodeDef} from "typings/comfy.js";
import type {RgthreeModelInfo} from "typings/rgthree.js";
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 {
drawInfoIcon,
drawNumberWidgetPart,
drawRoundedRectangle,
drawTogglePart,
fitString,
isLowQuality,
} from "./utils_canvas.js";
import {
RgthreeBaseHitAreas,
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";
import {RgthreeLoraInfoDialog} from "./dialog_info.js";
import {LORA_INFO_SERVICE} from "rgthree/common/model_info_service.js";
// import { RgthreePowerLoraChooserDialog } from "./dialog_power_lora_chooser.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";
/**
* 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]`);
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;
/** Keep track of the spacer, new lora widgets will go before it when it exists. */
private widgetButtonSpacer: ICustomWidget | null = null;
constructor(title = NODE_CLASS.title) {
super(title);
this.properties[PROP_LABEL_SHOW_STRENGTHS] = PROP_VALUE_SHOW_STRENGTHS_SINGLE;
// Prefetch loras list.
rgthreeApi.getLoras();
// [🤮] If ComfyUI is loading from API JSON it doesn't pass us the actual information at all
// (like, in a `configure` call) and tries to set the widget data on its own. Unfortunately,
// since Power Lora Loader has dynamic widgets, this fails on ComfyUI's side. We can do so after
// the fact but, unfortuntely, we need to do it after a timeout since we don't have any
// information at this point to be able to tell what data we need (like, even the node id, let
// alone the actual data).
if (rgthree.loadingApiJson) {
const fullApiJson = rgthree.loadingApiJson;
setTimeout(() => {
this.configureFromApiJson(fullApiJson);
}, 16);
}
}
private configureFromApiJson(fullApiJson: ComfyApiFormat) {
if (this.id == null) {
const [n, v] = this.logger.errorParts("Cannot load from API JSON without node id.");
console[n]?.(...v);
return;
}
const nodeData =
fullApiJson[this.id] || fullApiJson[String(this.id)] || fullApiJson[Number(this.id)];
if (nodeData == null) {
const [n, v] = this.logger.errorParts(`No node found in API JSON for node id ${this.id}.`);
console[n]?.(...v);
return;
}
this.configure({
widgets_values: Object.values(nodeData.inputs).filter(
(input) => typeof (input as any)?.["lora"] === "string",
),
});
}
/**
* Handles configuration from a saved workflow by first removing our default widgets that were
* added in `onNodeCreated`, letting `super.configure` and do nothing, then create our lora
* widgets and, finally, add back in our default widgets.
*/
override configure(
info: ISerialisedNode | {widgets_values: ISerialisedNode["widgets_values"]},
): void {
while (this.widgets?.length) this.removeWidget(0);
this.widgetButtonSpacer = null;
// Since we may be calling into configure manually for just widgets_values setting (like, from
// API JSON) we want to only call the parent class's configure with a real ISerialisedNode data.
if ((info as ISerialisedNode).id != null) {
super.configure(info as ISerialisedNode);
}
(this as any)._tempWidth = this.size[0];
(this as any)._tempHeight = this.size[1];
for (const widgetValue of info.widgets_values || []) {
if ((widgetValue as PowerLoraLoaderWidgetValue)?.lora !== undefined) {
const widget = this.addNewLoraWidget();
widget.value = {...(widgetValue as PowerLoraLoaderWidgetValue)};
}
}
this.addNonLoraWidgets();
this.size[0] = (this as any)._tempWidth;
this.size[1] = Math.max((this as any)._tempHeight, this.computeSize()[1]);
}
/**
* Adds the non-lora widgets. If we'll be configured then we remove them and add them back, so
* this is really only for newly created nodes in the current session.
*/
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 slot. */
private addNewLoraWidget(lora?: string) {
this.loraWidgetsCounter++;
const widget = this.addCustomWidget(
new PowerLoraLoaderWidget("lora_" + this.loraWidgetsCounter),
) as PowerLoraLoaderWidget;
if (lora) widget.setLora(lora);
if (this.widgetButtonSpacer) {
moveArrayItem(this.widgets, widget, this.widgets.indexOf(this.widgetButtonSpacer));
}
return widget;
}
/** Adds the non-lora widgets around any lora ones that may be there from configuration. */
private addNonLoraWidgets() {
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}),
) as RgthreeDividerWidget;
this.addCustomWidget(
new RgthreeBetterButtonWidget(
" Add Lora",
(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) => {
rgthreeApi.getLoras().then((lorasDetails) => {
const loras = lorasDetails.map((l) => l.file);
showLoraChooser(
event as MouseEvent,
(value: IContextMenuValue | string) => {
if (typeof value === "string") {
if (value.includes("Power Lora Chooser")) {
// new RgthreePowerLoraChooserDialog().show();
} else if (value !== "NONE") {
this.addNewLoraWidget(value);
const computed = this.computeSize();
const tempHeight = (this as any)._tempHeight ?? 15;
this.size[1] = Math.max(tempHeight, computed[1]);
this.setDirtyCanvas(true, true);
}
}
// }, null, ["⚡️ Power Lora Chooser", ...loras]);
},
null,
[...loras],
);
});
return true;
},
),
);
}
/**
* 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);
// No slot, let's see if it's a widget.
if (!slot) {
let lastWidget = null;
for (const widget of this.widgets) {
// If last_y isn't set, something is wrong. Bail.
if (!widget.last_y) return;
if (canvasY > this.pos[1] + widget.last_y) {
lastWidget = widget;
continue;
}
break;
}
// Only care about lora widget clicks.
if (lastWidget?.name?.startsWith("lora_")) {
return {widget: lastWidget, output: {type: "LORA WIDGET"}};
}
}
return slot;
}
/**
* Working with the overridden `getSlotInPosition` above, this method checks if the passed in
* option is actually a widget from it and then hijacks the context menu all together.
*/
override getSlotMenuOptions(slot: IFoundSlot) {
// Oddly, LiteGraph doesn't call back into our node with a custom menu (even though it let's us
// define a custom menu to begin with... wtf?). So, we'll return null so the default is not
// triggered and then we'll just show one ourselves because.. yea.
if (slot?.widget?.name?.startsWith("lora_")) {
const widget = slot.widget as PowerLoraLoaderWidget;
const index = this.widgets.indexOf(widget);
const canMoveUp = !!this.widgets[index - 1]?.name?.startsWith("lora_");
const canMoveDown = !!this.widgets[index + 1]?.name?.startsWith("lora_");
const menuItems: (IContextMenuValue | null)[] = [
{
content: ` Show Info`,
callback: () => {
widget.showLoraInfoDialog();
},
},
null, // Divider
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
},
},
{
content: `⬆️ Move Up`,
disabled: !canMoveUp,
callback: () => {
moveArrayItem(this.widgets, widget, index - 1);
},
},
{
content: `⬇️ Move Down`,
disabled: !canMoveDown,
callback: () => {
moveArrayItem(this.widgets, widget, index + 1);
},
},
{
content: `🗑️ Remove`,
callback: () => {
removeArrayItem(this.widgets, widget);
},
},
];
new LiteGraph.ContextMenu(menuItems, {
title: "LORA WIDGET",
event: rgthree.lastCanvasMouseEvent!,
});
// [🤮] ComfyUI doesn't have a possible return type as falsy, even though the impl skips the
// menu when the return is falsy. Casting as any.
return undefined as any;
}
return this.defaultGetSlotMenuOptions(slot);
}
/**
* When `refreshComboInNode` is called from ComfyUI, then we'll kick off a fresh loras fetch.
*/
override refreshComboInNode(defs: any) {
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 as any)?.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 as any)?.on != null) {
(widget.value as any).on = toggledTo;
}
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
addConnectionLayoutSupport(NODE_CLASS, app, [
["Left", "Right"],
["Right", "Left"],
]);
setTimeout(() => {
NODE_CLASS.category = comfyClass.category;
});
}
override getHelp() {
return `
<p>
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.
</p>
<ul>
<li><p>
Add as many Lora's as you would like by clicking the "+ Add Lora" button.
There's no real limit!
</p></li>
<li><p>
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.
</p></li>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>${PROP_LABEL_SHOW_STRENGTHS}</code> - 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.
</p></li>
</ul>
</li>
</ul>`;
}
}
/**
* 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<{type: string}> {
override value = {type: "PowerLoraLoaderHeaderWidget"};
override readonly type = "custom";
protected override hitAreas: RgthreeBaseHitAreas<"toggle"> = {
toggle: {bounds: [0, 0] as Vector2, onDown: this.onToggleDown},
};
private showModelAndClip: boolean | null = null;
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: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
(node as RgthreePowerLoraLoader).toggleAllLoras();
this.cancelMouseDown();
return true;
}
}
const DEFAULT_LORA_WIDGET_DATA: PowerLoraLoaderWidgetValue = {
on: true,
lora: null as string | null,
strength: 1,
strengthTwo: null as number | null,
};
type PowerLoraLoaderWidgetValue = {
on: boolean;
lora: string | null;
strength: number;
strengthTwo: number | null;
};
/**
* The PowerLoaderWidget that combines several custom drawing and functionality in a single row.
*/
class PowerLoraLoaderWidget extends RgthreeBaseWidget<PowerLoraLoaderWidgetValue> {
override readonly type = "custom";
/** Whether the strength has changed with mouse move (to cancel mouse up). */
private haveMouseMovedStrength = false;
private loraInfoPromise: Promise<RgthreeModelInfo | null> | null = null;
private loraInfo: RgthreeModelInfo | null = null;
private showModelAndClip: boolean | null = null;
protected override hitAreas: RgthreeBaseHitAreas<
| "toggle"
| "lora"
// | "info"
| "strengthDec"
| "strengthVal"
| "strengthInc"
| "strengthAny"
| "strengthTwoDec"
| "strengthTwoVal"
| "strengthTwoInc"
| "strengthTwoAny"
> = {
toggle: {bounds: [0, 0] as Vector2, onDown: this.onToggleDown},
lora: {bounds: [0, 0] as Vector2, onClick: this.onLoraClick},
// info: { bounds: [0, 0] as Vector2, onDown: this.onInfoDown },
strengthDec: {bounds: [0, 0] as Vector2, onClick: this.onStrengthDecDown},
strengthVal: {bounds: [0, 0] as Vector2, onClick: this.onStrengthValUp},
strengthInc: {bounds: [0, 0] as Vector2, onClick: this.onStrengthIncDown},
strengthAny: {bounds: [0, 0] as Vector2, onMove: this.onStrengthAnyMove},
strengthTwoDec: {bounds: [0, 0] as Vector2, onClick: this.onStrengthTwoDecDown},
strengthTwoVal: {bounds: [0, 0] as Vector2, onClick: this.onStrengthTwoValUp},
strengthTwoInc: {bounds: [0, 0] as Vector2, onClick: this.onStrengthTwoIncDown},
strengthTwoAny: {bounds: [0, 0] as Vector2, onMove: this.onStrengthTwoAnyMove},
};
constructor(name: string) {
super(name);
}
private _value = {
on: true,
lora: null as string | null,
strength: 1,
strengthTwo: null as number | null,
};
set value(v) {
this._value = v;
// 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;
}
}
this.getLoraInfo();
}
get value() {
return this._value;
}
setLora(lora: string) {
this._value.lora = lora;
this.getLoraInfo();
}
/** 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;
const lowQuality = isLowQuality();
const midY = posY + height * 0.5;
// We'll move posX along as we draw things.
let posX = margin;
// Draw the background.
drawRoundedRectangle(ctx, {pos: [posX, posY], size: [node.size[0] - margin * 2, height]});
// Draw the toggle
this.hitAreas.toggle.bounds = drawTogglePart(ctx, {posX, posY, height, value: this.value.on});
posX += this.hitAreas.toggle.bounds[1] + innerMargin;
// If low quality, then we're done rendering.
if (lowQuality) {
ctx.restore();
return;
}
// If we're not toggled on, then make everything after faded.
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
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);
let textColor: string | undefined = undefined;
if (this.loraInfo?.strengthMax != null && strengthValue > this.loraInfo?.strengthMax) {
textColor = "#c66";
} else if (this.loraInfo?.strengthMin != null && strengthValue < this.loraInfo?.strengthMin) {
textColor = "#c66";
}
const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, {
posX: node.size[0] - margin - innerMargin - innerMargin,
posY,
height,
value: strengthValue,
direction: -1,
textColor,
});
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;
let textColor: string | undefined = undefined;
if (this.loraInfo?.strengthMax != null && this.value.strength > this.loraInfo?.strengthMax) {
textColor = "#c66";
} else if (
this.loraInfo?.strengthMin != null &&
this.value.strength < this.loraInfo?.strengthMin
) {
textColor = "#c66";
}
const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, {
posX: rposX,
posY,
height,
value: this.value.strength ?? 1,
direction: -1,
textColor,
});
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 infoIconSize = height * 0.66;
const infoWidth = infoIconSize + innerMargin + innerMargin;
// Draw an info emoji; if checks if it's enabled (to quickly turn it on or off)
if ((this.hitAreas as any)["info"]) {
rposX -= innerMargin;
drawInfoIcon(ctx, rposX - infoIconSize, posY + (height - infoIconSize) / 2, infoIconSize);
// ctx.fillText('', posX, midY);
(this.hitAreas as any).info.bounds = [rposX - infoIconSize, infoWidth];
rposX = rposX - infoIconSize - innerMargin;
}
// Draw lora label
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.hitAreas.lora.bounds = [posX, loraWidth];
posX += loraWidth + innerMargin;
ctx.globalAlpha = app.canvas.editor_alpha;
ctx.restore();
}
override serializeValue(
node: TLGraphNode,
index: number,
): PowerLoraLoaderWidgetValue | Promise<PowerLoraLoaderWidgetValue> {
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;
}
onToggleDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.value.on = !this.value.on;
this.cancelMouseDown(); // Clear the down since we handle it.
return true;
}
onInfoDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.showLoraInfoDialog();
}
onLoraClick(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
showLoraChooser(event, (value: IContextMenuValue) => {
if (typeof value === "string") {
this.value.lora = value;
this.loraInfo = null;
this.getLoraInfo();
}
node.setDirtyCanvas(true, true);
});
this.cancelMouseDown();
}
onStrengthDecDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.stepStrength(-1, false);
}
onStrengthIncDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.stepStrength(1, false);
}
onStrengthTwoDecDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.stepStrength(-1, true);
}
onStrengthTwoIncDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.stepStrength(1, true);
}
onStrengthAnyMove(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.doOnStrengthAnyMove(event, false);
}
onStrengthTwoAnyMove(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
this.doOnStrengthAnyMove(event, true);
}
private doOnStrengthAnyMove(event: CanvasMouseEvent, isTwo = false) {
if (event.deltaX) {
let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength";
this.haveMouseMovedStrength = true;
this.value[prop] = (this.value[prop] ?? 1) + event.deltaX * 0.05;
}
}
onStrengthValUp(event: CanvasPointerEvent, pos: Vector2, node: TLGraphNode) {
this.doOnStrengthValUp(event, false);
}
onStrengthTwoValUp(event: CanvasPointerEvent, pos: Vector2, node: TLGraphNode) {
this.doOnStrengthValUp(event, true);
}
private doOnStrengthValUp(event: CanvasPointerEvent, 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);
}
override onMouseUp(event: CanvasPointerEvent, pos: Vector2, node: TLGraphNode): boolean | void {
super.onMouseUp(event, pos, node);
this.haveMouseMovedStrength = false;
}
showLoraInfoDialog() {
if (!this.value.lora || this.value.lora === "None") {
return;
}
const infoDialog = new RgthreeLoraInfoDialog(this.value.lora).show();
infoDialog.addEventListener("close", ((e: CustomEvent<{dirty: boolean}>) => {
if (e.detail.dirty) {
this.getLoraInfo(true);
}
}) as EventListener);
}
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;
}
private getLoraInfo(force = false) {
if (!this.loraInfoPromise || force == true) {
let promise;
if (this.value.lora && this.value.lora != "None") {
promise = LORA_INFO_SERVICE.getInfo(this.value.lora, force, true);
} else {
promise = Promise.resolve(null);
}
this.loraInfoPromise = promise.then((v) => (this.loraInfo = v));
}
return this.loraInfoPromise;
}
}
/** An uniformed name reference to the node class. */
const NODE_CLASS = RgthreePowerLoraLoader;
/** Register the node. */
app.registerExtension({
name: "rgthree.PowerLoraLoader",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NODE_CLASS.type) {
NODE_CLASS.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,254 @@
import type {
IWidget,
INodeInputSlot,
LGraphCanvas as TLGraphCanvas,
LGraphNodeConstructor,
IContextMenuValue,
INodeOutputSlot,
ISlotType,
ISerialisedNode,
LLink,
IBaseWidget,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {moveArrayItem} from "rgthree/common/shared_utils.js";
const PROPERTY_HIDE_TYPE_SELECTOR = "hideTypeSelector";
const PRIMITIVES = {
STRING: "STRING",
// "STRING (multiline)": "STRING",
INT: "INT",
FLOAT: "FLOAT",
BOOLEAN: "BOOLEAN",
};
class RgthreePowerPrimitive extends RgthreeBaseServerNode {
static override title = NodeTypesString.POWER_PRIMITIVE;
static override type = NodeTypesString.POWER_PRIMITIVE;
static comfyClass = NodeTypesString.POWER_PRIMITIVE;
private outputTypeWidget!: IWidget;
private valueWidget!: IWidget;
private typeState: string = '';
static "@hideTypeSelector" = {type: "boolean"};
override properties!: RgthreeBaseServerNode["properties"] & {
[PROPERTY_HIDE_TYPE_SELECTOR]: boolean;
};
constructor(title = NODE_CLASS.title) {
super(title);
this.properties[PROPERTY_HIDE_TYPE_SELECTOR] = false;
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
}
/**
* Adds the non-lora widgets. If we'll be configured then we remove them and add them back, so
* this is really only for newly created nodes in the current session.
*/
override onNodeCreated() {
super.onNodeCreated?.();
this.addInitialWidgets();
}
/**
* Ensures to set the type widget whenever we configure.
*/
override configure(info: ISerialisedNode): void {
super.configure(info);
// Update BOOL to BOOLEAN due to a bug using BOOL instead of BOOLEAN.
if (this.outputTypeWidget.value === 'BOOL') {
this.outputTypeWidget.value = 'BOOLEAN';
}
setTimeout(() => {
this.setTypedData();
});
}
/**
* Adds menu options for the node: quick toggle to show/hide the first widget, and a menu-option
* to change the type (for easier changing when hiding the first widget).
*/
override getExtraMenuOptions(
canvas: TLGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
) {
const that = this;
super.getExtraMenuOptions(canvas, options);
const isHidden = !!this.properties[PROPERTY_HIDE_TYPE_SELECTOR];
const menuItems = [
{
content: `${isHidden ? "Show" : "Hide"} Type Selector Widget`,
callback: (...args: any[]) => {
this.setProperty(
PROPERTY_HIDE_TYPE_SELECTOR,
!this.properties[PROPERTY_HIDE_TYPE_SELECTOR],
);
},
},
{
content: `Set type`,
submenu: {
options: Object.keys(PRIMITIVES),
callback(value: any, ...args: any[]) {
that.outputTypeWidget.value = value;
that.setTypedData();
},
},
},
];
options.splice(0, 0, ...menuItems, null);
return [];
}
private addInitialWidgets() {
if (!this.outputTypeWidget) {
this.outputTypeWidget = this.addWidget(
"combo",
"type",
"STRING",
(...args) => {
this.setTypedData();
},
{
values: Object.keys(PRIMITIVES),
},
) as IWidget;
this.outputTypeWidget.hidden = this.properties[PROPERTY_HIDE_TYPE_SELECTOR];
}
this.setTypedData();
}
/**
* Sets the correct inputs, outputs, and widgets for the designated type (with the
* `outputTypeWidget`) being the source of truth.
*/
private setTypedData() {
const name = "value";
const type = this.outputTypeWidget.value as string;
const linked = !!this.inputs?.[0]?.link;
const newTypeState = `${type}|${linked}`;
if (this.typeState == newTypeState) return;
this.typeState = newTypeState;
let value = this.valueWidget?.value ?? null;
let newWidget: IWidget | null= null;
// If we're linked, then set the UI to an empty string widget input, since the ComfyUI is rather
// confusing by showing a value that is not the actual value used (from the input).
if (linked) {
newWidget = ComfyWidgets["STRING"](this, name, ["STRING"], app).widget;
newWidget.value = "";
} else if (type == "STRING") {
newWidget = ComfyWidgets["STRING"](this, name, ["STRING", {multiline: true}], app).widget;
newWidget.value = value ? "" : String(value);
} else if (type === "INT" || type === "FLOAT") {
const isFloat = type === "FLOAT";
newWidget = this.addWidget("number", name, value ?? 1 as any, undefined, {
precision: isFloat ? 1 : 0,
step2: isFloat ? 0.1 : 0,
}) as IWidget;
value = Number(value);
value = value == null || isNaN(value) ? 0 : value;
newWidget.value = value;
} else if (type === "BOOLEAN") {
newWidget = this.addWidget("toggle", name, !!(value ?? true), undefined, {
on: "true",
off: "false",
}) as IWidget;
if (typeof value === "string") {
value = !["false", "null", "None", "", "0"].includes(value.toLowerCase());
}
newWidget.value = !!value;
}
if (newWidget == null) {
throw new Error(`Unsupported type "${type}".`);
}
if (this.valueWidget) {
this.replaceWidget(this.valueWidget, newWidget);
} else {
if (!this.widgets.includes(newWidget)) {
this.addCustomWidget(newWidget);
}
moveArrayItem(this.widgets, newWidget, 1);
}
this.valueWidget = newWidget;
// Set the input data.
if (!this.inputs?.length) {
this.addInput("value", "*", {widget: this.valueWidget as any});
} else {
this.inputs[0]!.widget = this.valueWidget as any;
}
// Set the output data.
const output = this.outputs[0]!;
const outputLabel = output.label === "*" || output.label === output.type ? null : output.label;
output.type = type;
output.label = outputLabel || output.type;
}
/**
* Sets the correct typed data when we change any connections (really care about
* onnecting/disconnecting the value input.)
*/
override onConnectionsChange(
type: ISlotType,
index: number,
isConnected: boolean,
link_info: LLink | null | undefined,
inputOrOutput: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.apply(this, [...arguments] as any);
if (this.inputs.includes(inputOrOutput as INodeInputSlot)) {
this.setTypedData();
}
}
/**
* Sets the correct output type widget state when our `PROPERTY_HIDE_TYPE_SELECTOR` changes.
*/
override onPropertyChanged(name: string, value: unknown, prev_value?: unknown): boolean {
if (name === PROPERTY_HIDE_TYPE_SELECTOR) {
if (!this.outputTypeWidget) {
return true;
}
this.outputTypeWidget.hidden = this.properties[PROPERTY_HIDE_TYPE_SELECTOR];
if (this.outputTypeWidget.hidden) {
this.outputTypeWidget.computeLayoutSize = () => ({
minHeight: 0,
minWidth: 0,
maxHeight: 0,
maxWidth: 0,
});
} else {
this.outputTypeWidget.computeLayoutSize = undefined;
}
}
return true;
}
}
/** An uniformed name reference to the node class. */
const NODE_CLASS = RgthreePowerPrimitive;
/** Register the node. */
app.registerExtension({
name: "rgthree.PowerPrimitive",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NODE_CLASS.type) {
NODE_CLASS.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,48 @@
import type {LGraphNode, LGraphNodeConstructor} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {PowerPrompt} from "./base_power_prompt.js";
import {NodeTypesString} from "./constants.js";
let nodeData: ComfyNodeDef | null = null;
app.registerExtension({
name: "rgthree.PowerPrompt",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, passedNodeData: ComfyNodeDef) {
if (passedNodeData.name.includes("Power Prompt") && passedNodeData.name.includes("rgthree")) {
nodeData = passedNodeData;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
(this as any).powerPrompt = new PowerPrompt(this as LGraphNode, passedNodeData);
};
addConnectionLayoutSupport(nodeType as LGraphNodeConstructor, app, [
["Left", "Right"],
["Right", "Left"],
]);
}
},
async loadedGraphNode(node: LGraphNode) {
if (node.type === NodeTypesString.POWER_PROMPT) {
setTimeout(() => {
// If the first output is STRING, then it's the text output from the initial launch.
// Let's port it to the new
if (node.outputs[0]!.type === "STRING") {
if (node.outputs[0]!.links) {
node.outputs[3]!.links = node.outputs[3]!.links || [];
for (const link of node.outputs[0]!.links) {
node.outputs[3]!.links.push(link);
(node.graph || app.graph).links[link]!.origin_slot = 3;
}
node.outputs[0]!.links = null;
}
node.outputs[0]!.type = nodeData!.output![0] as string;
node.outputs[0]!.name = nodeData!.output_name![0] || (node.outputs[0]!.type as string);
node.outputs[0]!.color_on = undefined;
node.outputs[0]!.color_off = undefined;
}
}, 50);
}
},
});

View File

@@ -0,0 +1,461 @@
import type {
LGraphNode,
IWidget,
Vector2,
CanvasMouseEvent,
} from "@comfyorg/frontend";
import type {ComfyNodeDef} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {debounce} from "rgthree/common/shared_utils.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {RgthreeBaseHitAreas, RgthreeBaseWidget, RgthreeBaseWidgetBounds} from "./utils_widgets.js";
import {
drawPlusIcon,
drawRoundedRectangle,
drawWidgetButton,
isLowQuality,
measureText,
} from "./utils_canvas.js";
import {rgthree} from "./rgthree.js";
type Vector4 = [number, number, number, number];
const ALPHABET = "abcdefghijklmnopqrstuv".split("");
const OUTPUT_TYPES = ["STRING", "INT", "FLOAT", "BOOLEAN", "*"];
class RgthreePowerPuter extends RgthreeBaseServerNode {
static override title = NodeTypesString.POWER_PUTER;
static override type = NodeTypesString.POWER_PUTER;
static comfyClass = NodeTypesString.POWER_PUTER;
private outputTypeWidget!: OutputsWidget;
private expressionWidget!: IWidget;
private stabilizeBound = this.stabilize.bind(this);
constructor(title = NODE_CLASS.title) {
super(title);
// Note, configure will add as many as was in the stored workflow automatically.
this.addAnyInput(2);
this.addInitialWidgets();
}
// /**
// * We need to patch in the configure to fix a bug where Power Puter was using BOOL instead of
// * BOOLEAN.
// */
// override configure(info: ISerialisedNode): void {
// super.configure(info);
// // Update BOOL to BOOLEAN due to a bug using BOOL instead of BOOLEAN.
// this.outputTypeWidget
// }
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
}
override onConnectionsChange(...args: any[]): void {
super.onConnectionsChange?.apply(this, [...arguments] as any);
this.scheduleStabilize();
}
scheduleStabilize(ms = 64) {
return debounce(this.stabilizeBound, ms);
}
stabilize() {
removeUnusedInputsFromEnd(this, 1);
this.addAnyInput();
this.setOutputs();
}
private addInitialWidgets() {
if (!this.outputTypeWidget) {
this.outputTypeWidget = this.addCustomWidget(
new OutputsWidget("outputs", this),
) as OutputsWidget;
this.expressionWidget = ComfyWidgets["STRING"](
this,
"code",
["STRING", {multiline: true}],
app,
).widget;
}
}
private addAnyInput(num = 1) {
for (let i = 0; i < num; i++) {
this.addInput(ALPHABET[this.inputs.length]!, "*" as string);
}
}
private setOutputs() {
const desiredOutputs = this.outputTypeWidget.value.outputs;
for (let i = 0; i < Math.max(this.outputs.length, desiredOutputs.length); i++) {
const desired = desiredOutputs[i];
let output = this.outputs[i];
if (!desired && output) {
this.disconnectOutput(i);
this.removeOutput(i);
continue;
}
output = output || this.addOutput("", "");
const outputLabel =
output.label === "*" || output.label === output.type ? null : output.label;
output.type = String(desired);
output.label = outputLabel || output.type;
}
}
override getHelp() {
return `
<p>
The ${this.type!.replace("(rgthree)", "")} is a powerful and versatile node that opens the
door for a wide range of utility by offering mult-line code parsing for output. This node
can be used for simple string concatenation, or math operations; to an image dimension or a
node's widgets with advanced list comprehension.
If you want to output something in your workflow, this is the node to do it.
</p>
<ul>
<li><p>
Evaluate almost any kind of input and more, and choose your output from INT, FLOAT,
STRING, or BOOLEAN.
</p></li>
<li><p>
Connect some nodes and do simply math operations like <code>a + b</code> or
<code>ceil(1 / 2)</code>.
</p></li>
<li><p>
Or do more advanced things, like input an image, and get the width like
<code>a.shape[2]</code>.
</p></li>
<li><p>
Even more powerful, you can target nodes in the prompt that's sent to the backend. For
instance; if you have a Power Lora Loader node at id #5, and want to get a comma-delimited
list of the enabled loras, you could enter
<code>', '.join([v.lora for v in node(5).inputs.values() if 'lora' in v and v.on])</code>.
</p></li>
<li><p>
See more at the <a target="_blank"
href="https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter">rgthree-comfy
wiki</a>.
</p></li>
</ul>`;
}
}
/** An uniformed name reference to the node class. */
const NODE_CLASS = RgthreePowerPuter;
type OutputsWidgetValue = {
outputs: string[];
};
const OUTPUTS_WIDGET_CHIP_HEIGHT = LiteGraph.NODE_WIDGET_HEIGHT - 4;
const OUTPUTS_WIDGET_CHIP_SPACE = 4;
const OUTPUTS_WIDGET_CHIP_ARROW_WIDTH = 5.5;
const OUTPUTS_WIDGET_CHIP_ARROW_HEIGHT = 4;
/**
* The OutputsWidget is an advanced widget that has a background similar to others, but then a
* series of "chips" that correspond to the outputs of the node. The chips are dynamic and wrap to
* additional rows as space is needed. Additionally, there is a "+" chip to add more.
*/
class OutputsWidget extends RgthreeBaseWidget<OutputsWidgetValue> {
override readonly type = "custom";
private _value: OutputsWidgetValue = {outputs: ["STRING"]};
private rows = 1;
private neededHeight = LiteGraph.NODE_WIDGET_HEIGHT + 8;
private node!: RgthreePowerPuter;
protected override hitAreas: RgthreeBaseHitAreas<
| "add"
| "output0"
| "output1"
| "output2"
| "output3"
| "output4"
| "output5"
| "output6"
| "output7"
| "output8"
| "output9"
> = {
add: {bounds: [0, 0] as Vector2, onClick: this.onAddChipDown},
output0: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 0}},
output1: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 1}},
output2: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 2}},
output3: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 3}},
output4: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 4}},
output5: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 5}},
output6: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 6}},
output7: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 7}},
output8: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 8}},
output9: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 9}},
};
constructor(name: string, node: RgthreePowerPuter) {
super(name);
this.node = node;
}
set value(v: OutputsWidgetValue) {
// Handle a string being passed in, as the original Power Puter output widget was a string.
let outputs = typeof v === "string" ? [v] : [...v.outputs];
// Handle a case where the initial version used "BOOL" instead of "BOOLEAN" incorrectly.
outputs = outputs.map((o) => (o === "BOOL" ? "BOOLEAN" : o));
this._value.outputs = outputs;
}
get value(): OutputsWidgetValue {
return this._value;
}
/** Displays the menu to choose a new output type. */
onAddChipDown(
event: CanvasMouseEvent,
pos: Vector2,
node: LGraphNode,
bounds: RgthreeBaseWidgetBounds,
) {
new LiteGraph.ContextMenu(OUTPUT_TYPES, {
event: event,
title: "Add an output",
className: "rgthree-dark",
callback: (value) => {
if (isLowQuality()) return;
if (typeof value === "string" && OUTPUT_TYPES.includes(value)) {
this._value.outputs.push(value);
this.node.scheduleStabilize();
}
},
});
this.cancelMouseDown();
return true;
}
/** Displays a context menu tied to an output chip within our widget. */
onOutputChipDown(
event: CanvasMouseEvent,
pos: Vector2,
node: LGraphNode,
bounds: RgthreeBaseWidgetBounds,
) {
const options: Array<null | string> = [...OUTPUT_TYPES];
if (this.value.outputs.length > 1) {
options.push(null, "🗑️ Delete");
}
new LiteGraph.ContextMenu(options, {
event: event,
title: `Edit output #${bounds.data.index + 1}`,
className: "rgthree-dark",
callback: (value) => {
const index = bounds.data.index;
if (typeof value !== "string" || value === this._value.outputs[index] || isLowQuality()) {
return;
}
const output = this.node.outputs[index]!;
if (value.toLocaleLowerCase().includes("delete")) {
if (output.links?.length) {
rgthree.showMessage({
id: "puter-remove-linked-output",
type: "warn",
message: "[Power Puter] Removed and disconnected output from that was connected!",
timeout: 3000,
});
this.node.disconnectOutput(index);
}
this.node.removeOutput(index);
this._value.outputs.splice(index, 1);
this.node.scheduleStabilize();
return;
}
if (output.links?.length && value !== "*") {
rgthree.showMessage({
id: "puter-remove-linked-output",
type: "warn",
message:
"[Power Puter] Changing output type of linked output! You should check for" +
" compatibility.",
timeout: 3000,
});
}
this._value.outputs[index] = value;
this.node.scheduleStabilize();
},
});
this.cancelMouseDown();
return true;
}
/**
* Computes the layout size to ensure the height is what we need to accomodate all the chips;
* specifically, SPACE on the top, plus the CHIP_HEIGHT + SPACE underneath multiplied by the
* number of rows necessary.
*/
computeLayoutSize(node: LGraphNode) {
this.neededHeight =
OUTPUTS_WIDGET_CHIP_SPACE +
(OUTPUTS_WIDGET_CHIP_HEIGHT + OUTPUTS_WIDGET_CHIP_SPACE) * this.rows;
return {
minHeight: this.neededHeight,
maxHeight: this.neededHeight,
minWidth: 0, // Need just zero here to be flexible with the width.
};
}
/**
* Draws our nifty, advanced widget keeping track of the space and wrapping to multiple lines when
* more chips than can fit are shown.
*/
draw(ctx: CanvasRenderingContext2D, node: LGraphNode, w: number, posY: number, height: number) {
ctx.save();
// Despite what `height` was passed in, which is often not our actual height, we'll use oun
// calculated needed height.
height = this.neededHeight;
const margin = 10;
const innerMargin = margin * 0.33;
const width = node.size[0] - margin * 2;
let borderRadius = LiteGraph.NODE_WIDGET_HEIGHT * 0.5;
let midY = posY + height * 0.5;
let posX = margin;
let rposX = node.size[0] - margin;
// Draw the background encompassing everything, and move our current posX's to create space from
// the border.
drawRoundedRectangle(ctx, {pos: [posX, posY], size: [width, height], borderRadius});
posX += innerMargin * 2;
rposX -= innerMargin * 2;
// If low quality, then we're done.
if (isLowQuality()) {
ctx.restore();
return;
}
// Add put our "outputs" label, and a divider line.
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("outputs", posX, midY);
posX += measureText(ctx, "outputs") + innerMargin * 2;
ctx.stroke(new Path2D(`M ${posX} ${posY} v ${height}`));
posX += 1 + innerMargin * 2;
// Now, prepare our values for the chips; adjust the posY to be within the space, the height to
// be that of the chips, and the new midY for the chips.
const inititalPosX = posX;
posY += OUTPUTS_WIDGET_CHIP_SPACE;
height = OUTPUTS_WIDGET_CHIP_HEIGHT;
borderRadius = height * 0.5;
midY = posY + height / 2;
ctx.textAlign = "center";
ctx.lineJoin = ctx.lineCap = "round";
ctx.fillStyle = ctx.strokeStyle = LiteGraph.WIDGET_TEXT_COLOR;
let rows = 1;
const values = this.value?.outputs ?? [];
const fontSize = ctx.font.match(/(\d+)px/);
if (fontSize?.[1]) {
ctx.font = ctx.font.replace(fontSize[1], `${Number(fontSize[1]) - 2}`);
}
// Loop over our values, and add them from left to right, measuring the width before placing to
// see if we need to wrap the the next line, and updating the hitAreas of the chips.
let i = 0;
for (i; i < values.length; i++) {
const hitArea = this.hitAreas[`output${i}` as "output1"];
const isClicking = !!hitArea.wasMouseClickedAndIsOver;
hitArea.data.index = i;
const text = values[i]!;
const textWidth = measureText(ctx, text) + innerMargin * 2;
const width = textWidth + OUTPUTS_WIDGET_CHIP_ARROW_WIDTH + innerMargin * 5;
// If our width is too long, then wrap the values and increment our rows.
if (posX + width >= rposX) {
posX = inititalPosX;
posY = posY + height + 4;
midY = posY + height / 2;
rows++;
}
drawWidgetButton(
ctx,
{pos: [posX, posY], size: [width, height], borderRadius},
null,
isClicking,
);
const startX = posX;
posX += innerMargin * 2;
const newMidY = midY + (isClicking ? 1 : 0);
ctx.fillText(text, posX + textWidth / 2, newMidY);
posX += textWidth + innerMargin;
const arrow = new Path2D(
`M${posX} ${newMidY - OUTPUTS_WIDGET_CHIP_ARROW_HEIGHT / 2}
h${OUTPUTS_WIDGET_CHIP_ARROW_WIDTH}
l-${OUTPUTS_WIDGET_CHIP_ARROW_WIDTH / 2} ${OUTPUTS_WIDGET_CHIP_ARROW_HEIGHT} z`,
);
ctx.fill(arrow);
ctx.stroke(arrow);
posX += OUTPUTS_WIDGET_CHIP_ARROW_WIDTH + innerMargin * 2;
hitArea.bounds = [startX, posY, width, height] as Vector4;
posX += OUTPUTS_WIDGET_CHIP_SPACE; // Space Between
}
// Zero out and following hitAreas.
for (i; i < 9; i++) {
const hitArea = this.hitAreas[`output${i}` as "output1"];
if (hitArea.bounds[0] > 0) {
hitArea.bounds = [0, 0, 0, 0] as Vector4;
}
}
// Draw the add arrow, if we're not at the max.
const addHitArea = this.hitAreas["add"];
if (this.value.outputs.length < 10) {
const isClicking = !!addHitArea.wasMouseClickedAndIsOver;
const plusSize = 10;
let plusWidth = innerMargin * 2 + plusSize + innerMargin * 2;
if (posX + plusWidth >= rposX) {
posX = inititalPosX;
posY = posY + height + 4;
midY = posY + height / 2;
rows++;
}
drawWidgetButton(
ctx,
{size: [plusWidth, height], pos: [posX, posY], borderRadius},
null,
isClicking,
);
drawPlusIcon(ctx, posX + innerMargin * 2, midY + (isClicking ? 1 : 0), plusSize);
addHitArea.bounds = [posX, posY, plusWidth, height] as Vector4;
} else {
addHitArea.bounds = [0, 0, 0, 0] as Vector4;
}
// Set the rows now that we're drawn.
this.rows = rows;
ctx.restore();
}
}
/** Register the node. */
app.registerExtension({
name: "rgthree.PowerPuter",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NODE_CLASS.type) {
NODE_CLASS.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,117 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {NodeTypesString} from "./constants.js";
import {rgthree} from "./rgthree.js";
import {changeModeOfNodes, getConnectedInputNodesAndFilterPassThroughs} from "./utils.js";
const MODE_MUTE = 2;
const MODE_ALWAYS = 0;
class RandomUnmuterNode extends BaseAnyInputConnectedNode {
static override exposedActions = ["Mute all", "Enable all"];
static override type = NodeTypesString.RANDOM_UNMUTER;
override comfyClass = NodeTypesString.RANDOM_UNMUTER;
static override title = RandomUnmuterNode.type;
readonly modeOn = MODE_ALWAYS;
readonly modeOff = MODE_MUTE;
tempEnabledNode: LGraphNode | null = null;
processingQueue: boolean = false;
onQueueBound = this.onQueue.bind(this);
onQueueEndBound = this.onQueueEnd.bind(this);
onGraphtoPromptBound = this.onGraphtoPrompt.bind(this);
onGraphtoPromptEndBound = this.onGraphtoPromptEnd.bind(this);
constructor(title = RandomUnmuterNode.title) {
super(title);
rgthree.addEventListener("queue", this.onQueueBound);
rgthree.addEventListener("queue-end", this.onQueueEndBound);
rgthree.addEventListener("graph-to-prompt", this.onGraphtoPromptBound);
rgthree.addEventListener("graph-to-prompt-end", this.onGraphtoPromptEndBound);
this.onConstructed();
}
override onRemoved() {
rgthree.removeEventListener("queue", this.onQueueBound);
rgthree.removeEventListener("queue-end", this.onQueueEndBound);
rgthree.removeEventListener("graph-to-prompt", this.onGraphtoPromptBound);
rgthree.removeEventListener("graph-to-prompt-end", this.onGraphtoPromptEndBound);
}
onQueue(event: Event) {
this.processingQueue = true;
}
onQueueEnd(event: Event) {
this.processingQueue = false;
}
onGraphtoPrompt(event: Event) {
if (!this.processingQueue) {
return;
}
this.tempEnabledNode = null;
// Check that all are muted and, if so, choose one to unmute.
const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
let allMuted = true;
if (linkedNodes.length) {
for (const node of linkedNodes) {
if (node.mode !== this.modeOff) {
allMuted = false;
break;
}
}
if (allMuted) {
this.tempEnabledNode = linkedNodes[Math.floor(Math.random() * linkedNodes.length)] || null;
if (this.tempEnabledNode) {
changeModeOfNodes(this.tempEnabledNode, this.modeOn);
}
}
}
}
onGraphtoPromptEnd(event: Event) {
if (this.tempEnabledNode) {
changeModeOfNodes(this.tempEnabledNode, this.modeOff);
this.tempEnabledNode = null;
}
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
return false; // No-op, no widgets.
}
override getHelp(): string {
return `
<p>
Use this node to unmute on of its inputs randomly when the graph is queued (and, immediately
mute it back).
</p>
<ul>
<li><p>
NOTE: All input nodes MUST be muted to start; if not this node will not randomly unmute
another. (This is powerful, as the generated image can be dragged in and the chosen input
will already by unmuted and work w/o any further action.)
</p></li>
<li><p>
TIP: Connect a Repeater's output to this nodes input and place that Repeater on a group
without any other inputs, and it will mute/unmute the entire group.
</p></li>
</ul>
`;
}
}
app.registerExtension({
name: "rgthree.RandomUnmuter",
registerCustomNodes() {
RandomUnmuterNode.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == RandomUnmuterNode.title) {
(node as any)._tempWidth = node.size[0];
}
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
.rgthree-top-messages-container {
position: fixed;
z-index: 9999;
top: 0;
left: 0;
width: 100%;
height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
}
.rgthree-top-messages-container > div {
position: relative;
height: fit-content;
padding: 4px;
margin-top: -100px; /* re-set by JS */
opacity: 0;
transition: all 0.33s ease-in-out;
z-index: 3;
}
.rgthree-top-messages-container > div:last-child {
z-index: 2;
}
.rgthree-top-messages-container > div:not(.-show) {
z-index: 1;
}
.rgthree-top-messages-container > div.-show {
opacity: 1;
margin-top: 0px !important;
}
.rgthree-top-messages-container > div.-show {
opacity: 1;
transform: translateY(0%);
}
.rgthree-top-messages-container > div > div {
position: relative;
background: #353535;
color: #fff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: fit-content;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.88);
padding: 6px 12px;
border-radius: 4px;
font-family: Arial, sans-serif;
font-size: 14px;
}
.rgthree-top-messages-container > div > div > span {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.rgthree-top-messages-container > div > div > span svg {
width: 20px;
height: auto;
margin-right: 8px;
}
.rgthree-top-messages-container > div > div > span svg.icon-checkmark {
fill: #2e9720;
}
.rgthree-top-messages-container [type="warn"]::before,
.rgthree-top-messages-container [type="success"]::before {
content: '⚠️';
display: inline-block;
flex: 0 0 auto;
font-size: 18px;
margin-right: 4px;
line-height: 1;
}
.rgthree-top-messages-container [type="success"]::before {
content: '🎉';
}
.rgthree-top-messages-container a {
cursor: pointer;
text-decoration: underline;
color: #fc0;
margin-left: 4px;
display: inline-block;
line-height: 1;
}
.rgthree-top-messages-container a:hover {
color: #fc0;
text-decoration: none;
}
/* Fix node selector being crazy long b/c of array types. */
.litegraph.litesearchbox input,
.litegraph.litesearchbox select {
max-width: 250px;
}
/* There's no reason for this z-index to be so high. It layers on top of things it shouldn't,
(like pythongssss' image gallery, the properties panel, etc.) */
.comfy-multiline-input {
z-index: 1 !important;
}
.comfy-multiline-input:focus {
z-index: 2 !important;
}
.litegraph .dialog {
z-index: 3 !important; /* This is set to 1, but goes under the multi-line inputs, so bump it. */
}
@import '../common/css/buttons.scss';
@import '../common/css/dialog.scss';
@import '../common/css/menu.scss';
.rgthree-dialog.-settings {
width: 100%;
}
.rgthree-dialog.-settings fieldset {
border: 1px solid rgba(255, 255, 255, 0.25);
padding: 0 12px 8px;
margin-bottom: 16px;
}
.rgthree-dialog.-settings fieldset > legend {
margin-left: 8px;
padding: 0 8px;
opacity: 0.5;
}
.rgthree-dialog.-settings .formrow {
display: flex;
flex-direction: column;
}
.rgthree-dialog.-settings .formrow + .formrow {
border-top: 1px solid rgba(255, 255, 255, 0.25);
}
.rgthree-dialog.-settings .fieldrow {
display: flex;
flex-direction: row;
}
.rgthree-dialog.-settings .fieldrow > label {
flex: 1 1 auto;
user-select: none;
padding: 8px 12px 12px;
}
.rgthree-dialog.-settings .fieldrow > label span {
font-weight: bold;
}
.rgthree-dialog.-settings .fieldrow > label small {
display: block;
margin-top: 4px;
font-size: calc(11rem / 16);
opacity: 0.75;
padding-left: 16px;
}
.rgthree-dialog.-settings .fieldrow ~ .fieldrow {
font-size: 0.9rem;
border-top: 1px dotted rgba(255, 255, 255, 0.25);
}
.rgthree-dialog.-settings .fieldrow ~ .fieldrow label {
padding-left: 28px;
}
.rgthree-dialog.-settings .fieldrow:first-child:not(.-checked) ~ .fieldrow {
display: none;
}
.rgthree-dialog.-settings .fieldrow:hover {
background: rgba(255,255,255,0.1);
}
.rgthree-dialog.-settings .fieldrow ~ .fieldrow span {
font-weight: normal;
}
.rgthree-dialog.-settings .fieldrow > .fieldrow-value {
display: flex;
align-items: center;
justify-content: end;
flex: 0 0 auto;
width: 50%;
max-width: 230px;
}
.rgthree-dialog.-settings .fieldrow.-type-boolean > .fieldrow-value {
max-width: 64px;
}
.rgthree-dialog.-settings .fieldrow.-type-number input {
width: 48px;
text-align: right;
}
.rgthree-dialog.-settings .fieldrow input[type="checkbox"] {
width: 24px;
height: 24px;
cursor: pointer;
}
.rgthree-dialog.-settings .fieldrow fieldset.rgthree-checklist-group {
padding: 0;
border: 0;
margin: 0;
> span.rgthree-checklist-item {
display: inline-block;
white-space: nowrap;
padding-right: 6px;
vertical-align: middle;
input[type="checkbox"] {
width: 16px;
height: 16px;
}
label {
padding-left: 4px;
text-align: left;
cursor: pointer;
}
}
}
.rgthree-comfyui-settings-row div {
display: flex;
flex-direction: row;
align-items: center;
justify-content: end;
}
.rgthree-comfyui-settings-row div svg {
width: 36px;
height: 36px;
margin-right: 16px;
}
.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy,
.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
}
.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy svg,
.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-item svg {
fill: currentColor;
width: auto;
height: 16px;
margin-right: 6px;
}
.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-item svg.github-star {
fill: rgb(227, 179, 65);
}
.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy,
.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-label {
color: #dde;
background-color: #212121 !important;
margin: 0;
padding: 2px;
cursor: default;
opacity: 1;
padding: 4px;
font-weight: bold;
}
.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy {
font-size: 1.1em;
color: #fff;
background-color: #090909 !important;
justify-content: center;
padding: 4px 8px;
}
rgthree-progress-bar {
display: block;
position: relative;
z-index: 999;
top: 0;
left: 0;
height: 14px;
font-size: 10px;
width: 100%;
overflow: hidden;
box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.25);
box-shadow:
inset 0px -1px 0px rgba(0, 0, 0, 0.25),
0px 1px 0px rgba(255, 255, 255, 0.125);
}
* ~ rgthree-progress-bar,
.comfyui-body-bottom rgthree-progress-bar {
box-shadow:
0px -1px 0px rgba(0, 0, 0, 1),
inset 0px 1px 0px rgba(255, 255, 255, 0.15), inset 0px -1px 0px rgba(0, 0, 0, 0.25), 0px 1px 0px rgba(255, 255, 255, 0.125);
}
body:not([style*=grid]):not([class*=grid]) {
rgthree-progress-bar {
position: fixed;
top: 0px;
bottom: auto;
}
rgthree-progress-bar.rgthree-pos-bottom {
top: auto;
bottom: 0px;
}
}
.rgthree-debug-keydowns {
display: block;
position: fixed;
z-index: 1050;
top: 3px;
right: 8px;
font-size: 10px;
color: #fff;
font-family: sans-serif;
pointer-events: none;
}
.rgthree-comfy-about-badge-logo {
width: 20px;
height: 20px;
background: url(/rgthree/logo.svg?bg=transparent&fg=%2393c5fd);
background-size: 100% 100%;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import type {
IContextMenuOptions,
ContextMenu,
LGraphNode as TLGraphNode,
IWidget,
LGraphCanvas,
IContextMenuValue,
LGraphNodeConstructor,
ISerialisedNode,
IButtonWidget,
} from "@comfyorg/frontend";
import type {ComfyNodeDef, ComfyApiPrompt} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {rgthree} from "./rgthree.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {NodeTypesString} from "./constants.js";
const LAST_SEED_BUTTON_LABEL = "♻️ (Use Last Queued Seed)";
const SPECIAL_SEED_RANDOM = -1;
const SPECIAL_SEED_INCREMENT = -2;
const SPECIAL_SEED_DECREMENT = -3;
const SPECIAL_SEEDS = [SPECIAL_SEED_RANDOM, SPECIAL_SEED_INCREMENT, SPECIAL_SEED_DECREMENT];
interface SeedSerializedCtx {
inputSeed?: number;
seedUsed?: number;
}
class RgthreeSeed extends RgthreeBaseServerNode {
static override title = NodeTypesString.SEED;
static override type = NodeTypesString.SEED;
static comfyClass = NodeTypesString.SEED;
override serialize_widgets = true;
private logger = rgthree.newLogSession(`[Seed]`);
static override exposedActions = ["Randomize Each Time", "Use Last Queued Seed"];
static "@randomMax" = {type: "number"};
static "@randomMin" = {type: "number"};
lastSeed?: number = undefined;
serializedCtx: SeedSerializedCtx = {};
seedWidget!: IWidget;
lastSeedButton!: IWidget;
lastSeedValue: IWidget | null = null;
private handleApiHijackingBound = this.handleApiHijacking.bind(this);
constructor(title = RgthreeSeed.title) {
super(title);
this.properties["randomMax"] = 1125899906842624;
// We can have a full range of seeds, including negative. But, for the randomRange we'll
// only generate positives, since that's what folks assume.
this.properties["randomMin"] = 0;
rgthree.addEventListener(
"comfy-api-queue-prompt-before",
this.handleApiHijackingBound as EventListener,
);
}
override onPropertyChanged(prop: string, value: unknown, prevValue?: unknown): boolean {
if (prop === 'randomMax') {
this.properties["randomMax"] = Math.min(1125899906842624, Number(value as number));
} else if (prop === 'randomMin') {
this.properties["randomMin"] = Math.max(-1125899906842624, Number(value as number));
}
return true;
}
override onRemoved() {
rgthree.addEventListener(
"comfy-api-queue-prompt-before",
this.handleApiHijackingBound as EventListener,
);
}
override configure(info: ISerialisedNode): void {
super.configure(info);
if (this.properties?.["showLastSeed"]) {
this.addLastSeedValue();
}
}
override async handleAction(action: string) {
if (action === "Randomize Each Time") {
this.seedWidget.value = SPECIAL_SEED_RANDOM;
} else if (action === "Use Last Queued Seed") {
this.seedWidget.value = this.lastSeed != null ? this.lastSeed : this.seedWidget.value;
this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL;
this.lastSeedButton.disabled = true;
}
}
override onNodeCreated() {
super.onNodeCreated?.();
// Grab the already available widgets, and remove the built-in control_after_generate
for (const [i, w] of this.widgets.entries()) {
if (w.name === "seed") {
this.seedWidget = w; // as ComfyWidget;
this.seedWidget.value = SPECIAL_SEED_RANDOM;
} else if (w.name === "control_after_generate") {
this.widgets.splice(i, 1);
}
}
this.addWidget(
"button",
"🎲 Randomize Each Time",
"",
() => {
this.seedWidget.value = SPECIAL_SEED_RANDOM;
},
{serialize: false},
);
this.addWidget(
"button",
"🎲 New Fixed Random",
"",
() => {
this.seedWidget.value = this.generateRandomSeed();
},
{serialize: false},
);
this.lastSeedButton = this.addWidget(
"button",
LAST_SEED_BUTTON_LABEL,
"",
() => {
this.seedWidget.value = this.lastSeed != null ? this.lastSeed : this.seedWidget.value;
this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL;
this.lastSeedButton.disabled = true;
},
{width: 50, serialize: false} as any,
) as IButtonWidget;
this.lastSeedButton.disabled = true;
}
generateRandomSeed() {
let step = this.seedWidget.options.step || 1;
const randomMin = Number(this.properties['randomMin'] || 0);
const randomMax = Number(this.properties['randomMax'] || 1125899906842624);
const randomRange = (randomMax - randomMin) / (step / 10);
let seed = Math.floor(Math.random() * randomRange) * (step / 10) + randomMin;
if (SPECIAL_SEEDS.includes(seed)) {
seed = 0;
}
return seed;
}
override getExtraMenuOptions(canvas: LGraphCanvas, options: IContextMenuValue[]) {
super.getExtraMenuOptions?.apply(this, [...arguments] as any);
options.splice(options.length - 1, 0, {
content: "Show/Hide Last Seed Value",
callback: (
_value: IContextMenuValue,
_options: IContextMenuOptions,
_event: MouseEvent,
_parentMenu: ContextMenu | undefined,
_node: TLGraphNode,
) => {
this.properties["showLastSeed"] = !this.properties["showLastSeed"];
if (this.properties["showLastSeed"]) {
this.addLastSeedValue();
} else {
this.removeLastSeedValue();
}
},
});
return [];
}
addLastSeedValue() {
if (this.lastSeedValue) return;
this.lastSeedValue = ComfyWidgets["STRING"](
this,
"last_seed",
["STRING", {multiline: true}],
app,
).widget as unknown as IWidget;
this.lastSeedValue!.inputEl!.readOnly = true;
this.lastSeedValue!.inputEl!.style.fontSize = "0.75rem";
this.lastSeedValue!.inputEl!.style.textAlign = "center";
this.computeSize();
}
removeLastSeedValue() {
if (!this.lastSeedValue) return;
this.lastSeedValue!.inputEl!.remove();
this.widgets.splice(this.widgets.indexOf(this.lastSeedValue), 1);
this.lastSeedValue = null;
this.computeSize();
}
/**
* Intercepts the prompt right before ComfyUI sends it to the server (as fired from rgthree) so we
* can inspect the prompt and workflow data and change swap in the seeds.
*
* Note, the original implementation tried to change the widget value itself when the graph was
* queued (and the relied on ComfyUI serializing the data changed data) and then changing it back.
* This worked well until other extensions kept calling graphToPrompt during asynchronous
* operations within, causing the widget to get confused without a reliable state to reflect upon.
*/
handleApiHijacking(e: CustomEvent<ComfyApiPrompt>) {
// Don't do any work if we're muted/bypassed.
if (this.mode === LiteGraph.NEVER || this.mode === 4) {
return;
}
const workflow = e.detail.workflow;
const output = e.detail.output;
let workflowNode = workflow?.nodes?.find((n: ISerialisedNode) => n.id === this.id) ?? null;
let outputInputs = output?.[this.id]?.inputs;
if (
!workflowNode ||
!outputInputs ||
outputInputs[this.seedWidget.name || "seed"] === undefined
) {
const [n, v] = this.logger.warnParts(
`Node ${this.id} not found in prompt data sent to server. This may be fine if only ` +
`queuing part of the workflow. If not, then this could be a bug.`,
);
console[n]?.(...v);
return;
}
const seedToUse = this.getSeedToUse();
const seedWidgetndex = this.widgets.indexOf(this.seedWidget);
workflowNode.widgets_values![seedWidgetndex] = seedToUse;
outputInputs[this.seedWidget.name || "seed"] = seedToUse;
this.lastSeed = seedToUse;
if (seedToUse != this.seedWidget.value) {
this.lastSeedButton.name = `♻️ ${this.lastSeed}`;
this.lastSeedButton.disabled = false;
} else {
this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL;
this.lastSeedButton.disabled = true;
}
if (this.lastSeedValue) {
this.lastSeedValue.value = `Last Seed: ${this.lastSeed}`;
}
}
/**
* Determines a seed to use depending on the seed widget's current value and the last used seed.
* There are no sideffects to calling this method.
*/
private getSeedToUse() {
const inputSeed = Number(this.seedWidget.value);
let seedToUse: number | null = null;
// If our input seed was a special seed, then handle it.
if (SPECIAL_SEEDS.includes(inputSeed)) {
// If the last seed was not a special seed and we have increment/decrement, then do that on
// the last seed.
if (typeof this.lastSeed === "number" && !SPECIAL_SEEDS.includes(this.lastSeed)) {
if (inputSeed === SPECIAL_SEED_INCREMENT) {
seedToUse = this.lastSeed + 1;
} else if (inputSeed === SPECIAL_SEED_DECREMENT) {
seedToUse = this.lastSeed - 1;
}
}
// If we don't have a seed to use, or it's special seed (like we incremented into one), then
// we randomize.
if (seedToUse == null || SPECIAL_SEEDS.includes(seedToUse)) {
seedToUse = this.generateRandomSeed();
}
}
return seedToUse ?? inputSeed;
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeSeed);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
addConnectionLayoutSupport(RgthreeSeed, app, [
["Left", "Right"],
["Right", "Left"],
]);
setTimeout(() => {
RgthreeSeed.category = comfyClass.category;
});
}
}
app.registerExtension({
name: "rgthree.Seed",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === RgthreeSeed.type) {
RgthreeSeed.setUp(nodeType, nodeData);
}
},
});

View File

@@ -0,0 +1,34 @@
import type {Bookmark} from "../bookmark.js";
import {app} from "scripts/app.js";
import {NodeTypesString} from "../constants.js";
import {reduceNodesDepthFirst} from "../utils.js";
const SHORTCUT_DEFAULTS = "1234567890abcdefghijklmnopqrstuvwxyz".split("");
class BookmarksService {
/**
* Gets a list of the current bookmarks within the current workflow.
*/
getCurrentBookmarks(): Bookmark[] {
return reduceNodesDepthFirst<Bookmark[]>(app.graph.nodes, (n, acc) => {
if (n.type === NodeTypesString.BOOKMARK) {
acc.push(n as Bookmark);
}
}, []).sort((a, b) => a.title.localeCompare(b.title));
}
getExistingShortcuts() {
const bookmarkNodes = this.getCurrentBookmarks();
const usedShortcuts = new Set(bookmarkNodes.map((n) => n.shortcutKey));
return usedShortcuts;
}
getNextShortcut() {
const existingShortcuts = this.getExistingShortcuts();
return SHORTCUT_DEFAULTS.find((char) => !existingShortcuts.has(char)) ?? "1";
}
}
/** The BookmarksService singleton. */
export const SERVICE = new BookmarksService();

View File

@@ -0,0 +1,40 @@
// @ts-ignore
import {rgthreeConfig} from "rgthree/config.js";
import {getObjectValue, setObjectValue} from "rgthree/common/shared_utils.js";
import {rgthreeApi} from "rgthree/common/rgthree_api.js";
/**
* A singleton service exported as `SERVICE` to handle configuration routines.
*/
class ConfigService extends EventTarget {
getConfigValue(key: string, def?: any) {
return getObjectValue(rgthreeConfig, key, def);
}
getFeatureValue(key: string, def?: any) {
key = "features." + key.replace(/^features\./, "");
return getObjectValue(rgthreeConfig, key, def);
}
/**
* Given an object of key:value changes it will send to the server and wait for a successful
* response before setting the values on the local rgthreeConfig.
*/
async setConfigValues(changed: {[key: string]: any}) {
const body = new FormData();
body.append("json", JSON.stringify(changed));
const response = await rgthreeApi.fetchJson("/config", {method: "POST", body});
if (response.status === "ok") {
for (const [key, value] of Object.entries(changed)) {
setObjectValue(rgthreeConfig, key, value);
this.dispatchEvent(new CustomEvent("config-change", {detail: {key, value}}));
}
} else {
return false;
}
return true;
}
}
/** The ConfigService singleton. */
export const SERVICE = new ConfigService();

View File

@@ -0,0 +1,74 @@
import type {DynamicContextNodeBase} from "../dynamic_context_base.js";
import {NodeTypesString} from "../constants.js";
import {getConnectedOutputNodesAndFilterPassThroughs} from "../utils.js";
import {INodeInputSlot, INodeOutputSlot, INodeSlot, LGraphNode} from "@comfyorg/frontend";
export let SERVICE: ContextService;
const OWNED_PREFIX = "+";
const REGEX_PREFIX = /^[\+⚠️]\s*/;
const REGEX_EMPTY_INPUT = /^\+\s*$/;
export function stripContextInputPrefixes(name: string) {
return name.replace(REGEX_PREFIX, "");
}
export function getContextOutputName(inputName: string) {
if (inputName === "base_ctx") return "CONTEXT";
return stripContextInputPrefixes(inputName).toUpperCase();
}
export enum InputMutationOperation {
"UNKNOWN",
"ADDED",
"REMOVED",
"RENAMED",
}
export type InputMutation = {
operation: InputMutationOperation;
node: DynamicContextNodeBase;
slotIndex: number;
slot: INodeSlot;
};
export class ContextService {
constructor() {
if (SERVICE) {
throw new Error("ContextService was already instantiated.");
}
}
onInputChanges(node: any, mutation: InputMutation) {
const childCtxs = getConnectedOutputNodesAndFilterPassThroughs(
node,
node,
0,
) as DynamicContextNodeBase[];
for (const childCtx of childCtxs) {
childCtx.handleUpstreamMutation(mutation);
}
}
getDynamicContextInputsData(node: DynamicContextNodeBase) {
return node
.getContextInputsList()
.map((input: INodeInputSlot, index: number) => ({
name: stripContextInputPrefixes(input.name),
type: String(input.type),
index,
}))
.filter((i) => i.type !== "*");
}
getDynamicContextOutputsData(node: LGraphNode) {
return node.outputs.map((output: INodeOutputSlot, index: number) => ({
name: stripContextInputPrefixes(output.name),
type: String(output.type),
index,
}));
}
}
SERVICE = new ContextService();

View File

@@ -0,0 +1,231 @@
import type {LGraphGroup as TLGraphGroup} from "@comfyorg/frontend";
import type {BaseFastGroupsModeChanger} from "../fast_groups_muter.js";
import {app} from "scripts/app.js";
import {getGraphDependantNodeKey, getGroupNodes, reduceNodesDepthFirst} from "../utils.js";
type Vector4 = [number, number, number, number];
/**
* A service that keeps global state that can be shared by multiple FastGroupsMuter or
* FastGroupsBypasser nodes rather than calculate it on it's own.
*/
class FastGroupsService {
private msThreshold = 400;
private msLastUnsorted = 0;
private msLastAlpha = 0;
private msLastPosition = 0;
private groupsUnsorted: TLGraphGroup[] = [];
private groupsSortedAlpha: TLGraphGroup[] = [];
private groupsSortedPosition: TLGraphGroup[] = [];
private readonly fastGroupNodes: BaseFastGroupsModeChanger[] = [];
private runScheduledForMs: number | null = null;
private runScheduleTimeout: number | null = null;
private runScheduleAnimation: number | null = null;
private cachedNodeBoundings: {[key: string]: Vector4} | null = null;
constructor() {
// Don't need to do anything, wait until a signal.
}
addFastGroupNode(node: BaseFastGroupsModeChanger) {
this.fastGroupNodes.push(node);
// Schedule it because the node may not be ready to refreshWidgets (like, when added it may
// not have cloned properties to filter against, etc.).
this.scheduleRun(8);
}
removeFastGroupNode(node: BaseFastGroupsModeChanger) {
const index = this.fastGroupNodes.indexOf(node);
if (index > -1) {
this.fastGroupNodes.splice(index, 1);
}
// If we have no more group nodes, then clear out data; it could be because of a canvas clear.
if (!this.fastGroupNodes?.length) {
this.clearScheduledRun();
this.groupsUnsorted = [];
this.groupsSortedAlpha = [];
this.groupsSortedPosition = [];
}
}
private run() {
// We only run if we're scheduled, so if we're not, then bail.
if (!this.runScheduledForMs) {
return;
}
for (const node of this.fastGroupNodes) {
node.refreshWidgets();
}
this.clearScheduledRun();
this.scheduleRun();
}
private scheduleRun(ms = 500) {
// If we got a request for an immediate schedule and already have on scheduled for longer, then
// cancel the long one to expediate a fast one.
if (this.runScheduledForMs && ms < this.runScheduledForMs) {
this.clearScheduledRun();
}
if (!this.runScheduledForMs && this.fastGroupNodes.length) {
this.runScheduledForMs = ms;
this.runScheduleTimeout = setTimeout(() => {
this.runScheduleAnimation = requestAnimationFrame(() => this.run());
}, ms);
}
}
private clearScheduledRun() {
this.runScheduleTimeout && clearTimeout(this.runScheduleTimeout);
this.runScheduleAnimation && cancelAnimationFrame(this.runScheduleAnimation);
this.runScheduleTimeout = null;
this.runScheduleAnimation = null;
this.runScheduledForMs = null;
}
/**
* Returns the boundings for all nodes on the graph, then clears it after a short delay. This is
* to increase efficiency by caching the nodes' boundings when multiple groups are on the page.
*/
getBoundingsForAllNodes() {
if (!this.cachedNodeBoundings) {
this.cachedNodeBoundings = reduceNodesDepthFirst(
app.graph._nodes,
(node, acc) => {
let bounds = node.getBounding();
// If the bounds are zero'ed out, then we could be a subgraph that hasn't rendered yet and
// need to update them.
if (bounds[0] === 0 && bounds[1] === 0 && bounds[2] === 0 && bounds[3] === 0) {
const ctx = node.graph?.primaryCanvas?.canvas.getContext("2d");
if (ctx) {
node.updateArea(ctx);
bounds = node.getBounding();
}
}
acc[getGraphDependantNodeKey(node)] = bounds as Vector4;
},
{} as {[key: string]: Vector4},
);
setTimeout(() => {
this.cachedNodeBoundings = null;
}, 50);
}
return this.cachedNodeBoundings;
}
/**
* This overrides `TLGraphGroup.prototype.recomputeInsideNodes` to be much more efficient when
* calculating for many groups at once (only compute all nodes once in `getBoundingsForAllNodes`).
*/
recomputeInsideNodesForGroup(group: TLGraphGroup) {
// If the canvas is currently being dragged (includes if a group is being dragged around) then
// don't recompute anything.
if (app.canvas.isDragging) return;
const cachedBoundings = this.getBoundingsForAllNodes();
const nodes = group.graph!.nodes;
group._children.clear();
group.nodes.length = 0;
for (const node of nodes) {
const nodeBounding = cachedBoundings[getGraphDependantNodeKey(node)];
const nodeCenter =
nodeBounding &&
([nodeBounding[0] + nodeBounding[2] * 0.5, nodeBounding[1] + nodeBounding[3] * 0.5] as [
number,
number,
]);
if (nodeCenter) {
const grouBounds = group._bounding as unknown as [number, number, number, number];
if (
nodeCenter[0] >= grouBounds[0] &&
nodeCenter[0] < grouBounds[0] + grouBounds[2] &&
nodeCenter[1] >= grouBounds[1] &&
nodeCenter[1] < grouBounds[1] + grouBounds[3]
) {
group._children.add(node);
group.nodes.push(node);
}
}
}
}
/**
* Everything goes through getGroupsUnsorted, so we only get groups once. However, LiteGraph's
* `recomputeInsideNodes` is inefficient when calling multiple groups (it iterates over all nodes
* each time). So, we'll do our own dang thing, once.
*/
private getGroupsUnsorted(now: number) {
const canvas = app.canvas;
const graph = canvas.getCurrentGraph() ?? app.graph;
if (
// Don't recalculate nodes if we're moving a group (added by ComfyUI in app.js)
// TODO: This doesn't look available anymore... ?
!canvas.selected_group_moving &&
(!this.groupsUnsorted.length || now - this.msLastUnsorted > this.msThreshold)
) {
this.groupsUnsorted = [...graph._groups];
const subgraphs = graph.subgraphs?.values();
if (subgraphs) {
let s;
while ((s = subgraphs.next().value)) this.groupsUnsorted.push(...(s.groups ?? []));
}
for (const group of this.groupsUnsorted) {
this.recomputeInsideNodesForGroup(group);
group.rgthree_hasAnyActiveNode = getGroupNodes(group).some(
(n) => n.mode === LiteGraph.ALWAYS,
);
}
this.msLastUnsorted = now;
}
return this.groupsUnsorted;
}
private getGroupsAlpha(now: number) {
if (!this.groupsSortedAlpha.length || now - this.msLastAlpha > this.msThreshold) {
this.groupsSortedAlpha = [...this.getGroupsUnsorted(now)].sort((a, b) => {
return a.title.localeCompare(b.title);
});
this.msLastAlpha = now;
}
return this.groupsSortedAlpha;
}
private getGroupsPosition(now: number) {
if (!this.groupsSortedPosition.length || now - this.msLastPosition > this.msThreshold) {
this.groupsSortedPosition = [...this.getGroupsUnsorted(now)].sort((a, b) => {
// Sort by y, then x, clamped to 30.
const aY = Math.floor(a._pos[1] / 30);
const bY = Math.floor(b._pos[1] / 30);
if (aY == bY) {
const aX = Math.floor(a._pos[0] / 30);
const bX = Math.floor(b._pos[0] / 30);
return aX - bX;
}
return aY - bY;
});
this.msLastPosition = now;
}
return this.groupsSortedPosition;
}
getGroups(sort?: string) {
const now = +new Date();
if (sort === "alphanumeric") {
return this.getGroupsAlpha(now);
}
if (sort === "position") {
return this.getGroupsPosition(now);
}
return this.getGroupsUnsorted(now);
}
}
/** The FastGroupsService singleton. */
export const SERVICE = new FastGroupsService();

View File

@@ -0,0 +1,184 @@
/**
* A service responsible for capturing keys within LiteGraph's canvas, and outside of it, allowing
* nodes and other services to confidently determine what's going on.
*/
class KeyEventService extends EventTarget {
readonly downKeys: { [key: string]: boolean } = {};
readonly shiftDownKeys: { [key: string]: boolean } = {};
ctrlKey = false;
altKey = false;
metaKey = false;
shiftKey = false;
private readonly isMac: boolean = !!(
navigator.platform?.toLocaleUpperCase().startsWith("MAC") ||
(navigator as any).userAgentData?.platform?.toLocaleUpperCase().startsWith("MAC")
);
constructor() {
super();
this.initialize();
}
initialize() {
const that = this;
// [🤮] Sometimes ComfyUI and/or LiteGraph stop propagation of key events which makes it hard
// to determine if keys are currently pressed. To attempt to get around this, we'll hijack
// LiteGraph's processKey to try to get better consistency.
const processKey = LGraphCanvas.prototype.processKey;
LGraphCanvas.prototype.processKey = function (e: KeyboardEvent) {
if (e.type === "keydown" || e.type === "keyup") {
that.handleKeyDownOrUp(e);
}
return processKey.apply(this, [...arguments] as any) as any;
};
// Now that ComfyUI has more non-canvas UI (like the top bar), we listen on window as well, and
// de-dupe when we get multiple events from both window and/or LiteGraph.
window.addEventListener("keydown", (e) => {
that.handleKeyDownOrUp(e);
});
window.addEventListener("keyup", (e) => {
that.handleKeyDownOrUp(e);
});
// If we get a visibilitychange, then clear the keys since we can't listen for keys up/down when
// not visible.
document.addEventListener("visibilitychange", (e) => {
this.clearKeydowns();
});
// If we get a blur, then also clear the keys since we can't listen for keys up/down when
// blurred. This can happen w/o a visibilitychange, like a browser alert.
window.addEventListener("blur", (e) => {
this.clearKeydowns();
});
}
/**
* Adds a new queue item, unless the last is the same.
*/
handleKeyDownOrUp(e: KeyboardEvent) {
const key = e.key.toLocaleUpperCase();
// If we're already down, or already up, then ignore and don't fire.
if ((e.type === 'keydown' && this.downKeys[key] === true)
|| (e.type === 'keyup' && this.downKeys[key] === undefined)) {
return;
}
this.ctrlKey = !!e.ctrlKey;
this.altKey = !!e.altKey;
this.metaKey = !!e.metaKey;
this.shiftKey = !!e.shiftKey;
if (e.type === "keydown") {
this.downKeys[key] = true;
this.dispatchCustomEvent("keydown", { originalEvent: e });
// If SHIFT is pressed down as well, then we need to keep track of this separetly to "release"
// it once SHIFT is also released.
if (this.shiftKey && key !== 'SHIFT') {
this.shiftDownKeys[key] = true;
}
} else if (e.type === "keyup") {
// See https://github.com/rgthree/rgthree-comfy/issues/238
// A little bit of a hack, but Mac reportedly does something odd with copy/paste. ComfyUI
// gobbles the copy event propagation, but it happens for paste too and reportedly 'Enter' which
// I can't find a reason for in LiteGraph/comfy. So, for Mac only, whenever we lift a Command
// (META) key, we'll also clear any other keys.
if (key === "META" && this.isMac) {
this.clearKeydowns();
} else {
delete this.downKeys[key];
}
// If we're releasing the SHIFT key, then we may also be releasing all other keys we pressed
// during the SHIFT key as well. We should get an additional keydown for them after.
if (key === 'SHIFT') {
for (const key in this.shiftDownKeys) {
delete this.downKeys[key];
delete this.shiftDownKeys[key];
}
}
this.dispatchCustomEvent("keyup", { originalEvent: e });
}
}
private clearKeydowns() {
this.ctrlKey = false;
this.altKey = false;
this.metaKey = false;
this.shiftKey = false;
for (const key in this.downKeys) delete this.downKeys[key];
}
/**
* Wraps `dispatchEvent` for easier CustomEvent dispatching.
*/
private dispatchCustomEvent(event: string, detail?: any) {
if (detail != null) {
return this.dispatchEvent(new CustomEvent(event, { detail }));
}
return this.dispatchEvent(new CustomEvent(event));
}
/**
* Parses a shortcut string.
*
* - 's' => ['S']
* - 'shift + c' => ['SHIFT', 'C']
* - 'shift + meta + @' => ['SHIFT', 'META', '@']
* - 'shift + + + @' => ['SHIFT', '__PLUS__', '=']
* - '+ + p' => ['__PLUS__', 'P']
*/
private getKeysFromShortcut(shortcut: string | string[]) {
let keys;
if (typeof shortcut === "string") {
// Rip all spaces out. Note, Comfy swallows space, so we don't have to handle it. Otherwise,
// we would require space to be fed as "Space" or "Spacebar" instead of " ".
shortcut = shortcut.replace(/\s/g, "");
// Change a real "+" to something we can encode.
shortcut = shortcut.replace(/^\+/, "__PLUS__").replace(/\+\+/, "+__PLUS__");
keys = shortcut.split("+").map((i) => i.replace("__PLUS__", "+"));
} else {
keys = [...shortcut];
}
return keys.map((k) => k.toLocaleUpperCase());
}
/**
* Checks if all keys passed in are down.
*/
areAllKeysDown(keys: string | string[]) {
keys = this.getKeysFromShortcut(keys);
return keys.every((k) => {
return this.downKeys[k];
});
}
/**
* Checks if only the keys passed in are down; optionally and additionally allowing "shift" key.
*/
areOnlyKeysDown(keys: string | string[], alsoAllowShift = false) {
keys = this.getKeysFromShortcut(keys);
const allKeysDown = this.areAllKeysDown(keys);
const downKeysLength = Object.values(this.downKeys).length;
// All keys are down and they're the only ones.
if (allKeysDown && keys.length === downKeysLength) {
return true;
}
// Special case allowing the shift key in addition to the shortcut keys. This helps when a user
// may had originally defined "$" as a shortcut, but needs to press "shift + $" since it's an
// upper key character, etc.
if (alsoAllowShift && !keys.includes("SHIFT") && keys.length === downKeysLength - 1) {
// If we're holding down shift, have one extra key held down, and the original keys don't
// include shift, then we're good to go.
return allKeysDown && this.areAllKeysDown(["SHIFT"]);
}
return false;
}
}
/** The KeyEventService singleton. */
export const SERVICE = new KeyEventService();

View File

@@ -0,0 +1,71 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {wait} from "rgthree/common/shared_utils.js";
import {NodeTypesString} from "../constants.js";
type addNodeOptions = {
placement?: string;
};
/**
* A testing environment to make setting up, clearing, and queuing more predictable in an
* integration test environment.
*/
export class ComfyUITestEnvironment {
private lastNode: LGraphNode | null = null;
private maxY = 0;
constructor() {}
wait = wait;
async addNode(nodeString: string, options: addNodeOptions = {}) {
const [canvas, graph] = [app.canvas, app.graph];
const node = LiteGraph.createNode(nodeString)!;
let x = 0;
let y = 30;
if (this.lastNode) {
const placement = options.placement || "right";
if (placement === "under") {
x = this.lastNode.pos[0];
y = this.lastNode.pos[1] + this.lastNode.size[1] + 30;
} else if (placement === "right") {
x = this.lastNode.pos[0] + this.lastNode.size[0] + 100;
y = this.lastNode.pos[1];
} else if (placement === "start") {
x = 0;
y = this.maxY + 50;
}
}
canvas.graph!.add(node);
node.pos = [x, y];
canvas.selectNode(node);
app.graph.setDirtyCanvas(true, true);
await wait();
this.lastNode = node;
this.maxY = Math.max(this.maxY, y + this.lastNode.size[1]);
return (this.lastNode = node);
}
async clear() {
app.clean();
app.graph.clear();
const nodeConfig = await this.addNode(NodeTypesString.KSAMPLER_CONFIG);
const displayAny = await this.addNode(NodeTypesString.DISPLAY_ANY);
nodeConfig.widgets = nodeConfig.widgets || [];
nodeConfig.widgets[0]!.value = Math.round(Math.random() * 100);
nodeConfig.connect(0, displayAny, 0);
await this.queuePrompt();
app.clean();
app.graph.clear();
this.lastNode = null;
this.maxY = 0;
await wait();
}
async queuePrompt() {
await app.queuePrompt(0);
await wait(150);
}
}

View File

@@ -0,0 +1,167 @@
/**
* @fileoverview A set of methods that mimic a bit of the Jasmine testing library, but simpler and
* more succinct for manipulating a comfy integration test.
*
* Tests are not bundled by default, to test build with "--with-tests" and then invoke from the
* dev console like `rgthree_tests.TestDescribeLabel()`. The output is in the test itself.
*/
import { wait } from "rgthree/common/shared_utils.js";
declare global {
interface Window {
rgthree_tests: {
[key: string]: any;
};
}
}
window.rgthree_tests = window.rgthree_tests || {};
type TestContext = {
label?: string;
beforeEach?: Function[];
};
let contexts: TestContext[] = [];
export function describe(label: string, fn: Function) {
if (!label.startsWith('Test')) {
throw new Error('Test labels should start with "Test"');
}
window.rgthree_tests[label] = async () => {
await describeRun(label, fn);
};
return window.rgthree_tests[label];
}
export async function describeRun(label: string, fn: Function) {
await wait();
contexts.push({ label });
console.group(`[Start] ${contexts[contexts.length - 1]!.label}`);
await fn();
contexts.pop();
console.groupEnd();
}
export async function should(declaration: string, fn: Function) {
if (!contexts[contexts.length - 1]) {
throw Error("Called should outside of a describe.");
}
console.group(`...should ${declaration}`);
try {
for (const context of contexts) {
for (const beforeEachFn of context?.beforeEach || []) {
await beforeEachFn();
}
}
await fn();
} catch (e: any) {
fail(e);
}
console.groupEnd();
}
export async function beforeEach(fn: Function) {
if (!contexts[contexts.length - 1]) {
throw Error("Called beforeEach outside of a describe.");
}
const last = contexts[contexts.length - 1]!;
last.beforeEach = last?.beforeEach || [];
last.beforeEach.push(fn);
}
export function fail(e: Error) {
log(`X Failure: ${e}`, "color:#600; background:#fdd; padding: 2px 6px;");
}
function log(msg: string, styles: string) {
if (styles) {
console.log(`%c ${msg}`, styles);
} else {
console.log(msg);
}
}
class Expectation {
private propertyLabel: string | null = "";
private expectedLabel: string | null = "";
private verbLabel: string | null = "be";
private expectedFn!: (v: any) => boolean;
private value: any;
constructor(value: any) {
this.value = value;
}
toBe(labelOrExpected: any, maybeExpected?: any) {
const expected = maybeExpected !== undefined ? maybeExpected : labelOrExpected;
this.propertyLabel = maybeExpected !== undefined ? labelOrExpected : null;
this.expectedLabel = JSON.stringify(expected);
this.expectedFn = (v) => v == expected;
return this.toBeEval();
}
toMatchJson(labelOrExpected: any, maybeExpected?: any) {
const expected = maybeExpected !== undefined ? maybeExpected : labelOrExpected;
this.propertyLabel = maybeExpected !== undefined ? labelOrExpected : null;
this.expectedLabel = JSON.stringify(expected);
this.expectedFn = (v) => JSON.stringify(JSON.parse(v)) == JSON.stringify(JSON.parse(expected));
return this.toBeEval();
}
toBeUndefined(propertyLabel: string) {
this.expectedFn = (v) => v === undefined;
this.propertyLabel = propertyLabel || "";
this.expectedLabel = "undefined";
return this.toBeEval(true);
}
toBeNullOrUndefined(propertyLabel: string) {
this.expectedFn = (v) => v == null;
this.propertyLabel = propertyLabel || "";
this.expectedLabel = "null or undefined";
return this.toBeEval(true);
}
toBeTruthy(propertyLabel: string) {
this.expectedFn = (v) => !v;
this.propertyLabel = propertyLabel || "";
this.expectedLabel = "truthy";
return this.toBeEval(false);
}
toBeANumber(propertyLabel: string) {
this.expectedFn = (v) => typeof v === "number";
this.propertyLabel = propertyLabel || "";
this.expectedLabel = "a number";
return this.toBeEval();
}
toContain(labelOrExpected: any, maybeExpected?: any) {
const expected = maybeExpected !== undefined ? maybeExpected : labelOrExpected;
this.propertyLabel = maybeExpected !== undefined ? labelOrExpected : null;
this.verbLabel = 'contain';
this.expectedLabel = JSON.stringify(expected);
this.expectedFn = (v) => v.includes(expected);
return this.toBeEval();
}
toBeEval(strict = false) {
let evaluation = this.expectedFn(this.value);
let msg = `Expected ${this.propertyLabel ? this.propertyLabel + ` to ${this.verbLabel} ` : ""}${
this.expectedLabel
}`;
msg += evaluation ? "." : `, but was ${JSON.stringify(this.value)}`;
this.log(evaluation, msg);
return evaluation;
}
log(value: boolean, msg: string) {
if (value) {
log(`🗸 ${msg}`, "color:#060; background:#cec; padding: 2px 6px;");
} else {
log(`X ${msg}`, "color:#600; background:#fdd; padding: 2px 6px;");
}
}
}
export function expect(value: any, msg?: string) {
const expectation = new Expectation(value);
if (msg) {
expectation.log(value, msg);
}
return expectation;
}

View File

@@ -0,0 +1,41 @@
import type {LGraphNode} from "@comfyorg/frontend";
import type {ComfyUITestEnvironment} from "./comfyui_env";
export const PNG_1x1 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4v5ThPwAG7wKklwQ/bwAAAABJRU5ErkJggg==";
export const PNG_1x2 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEElEQVQIW2NgYGD4D8QM/wEHAwH/OMSHKAAAAABJRU5ErkJggg==";
export const PNG_2x1 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAD0lEQVQIW2NkYGD4D8QMAAUNAQFqjhCLAAAAAElFTkSuQmCC";
export async function pasteImageToLoadImageNode(
env: ComfyUITestEnvironment,
dataUrl?: string,
node?: LGraphNode,
) : Promise<LGraphNode> {
const dataArr = (dataUrl ?? PNG_1x1).split(",");
const mime = dataArr[0]!.match(/:(.*?);/)![1];
const bstr = atob(dataArr[1]!);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
const filename = `test_image_${+new Date()}.png`;
const file = new File([u8arr], filename, {type: mime});
if (!node) {
node = await env.addNode("LoadImage");
}
await (node as any).pasteFiles([file]);
let i = 0;
let good = false;
while (i++ < 10 || good) {
good = node.widgets![0]!.value === filename;
if (good) break;
await env.wait(100);
}
if (!good) {
throw new Error("Expected file not loaded.");
}
return node;
}

View File

@@ -0,0 +1,182 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {NodeTypesString} from "../constants.js";
import {wait} from "rgthree/common/shared_utils.js";
import {describe, should, beforeEach, expect, describeRun} from "../testing/runner.js";
import {ComfyUITestEnvironment} from "../testing/comfyui_env.js";
const env = new ComfyUITestEnvironment();
function verifyInputAndOutputName(
node: LGraphNode,
index: number,
inputName: string | null,
isLinked?: boolean,
) {
if (inputName != null) {
expect(node.inputs[index]!.name).toBe(`input ${index} name`, inputName);
}
if (isLinked) {
expect(node.inputs[index]!.link).toBeANumber(`input ${index} connection`);
} else if (isLinked === false) {
expect(node.inputs[index]!.link).toBeNullOrUndefined(`input ${index} connection`);
}
if (inputName != null) {
if (inputName === "+") {
expect(node.outputs[index]).toBeUndefined(`output ${index}`);
} else {
let outputName =
inputName === "base_ctx" ? "CONTEXT" : inputName.replace(/^\+\s/, "").toUpperCase();
expect(node.outputs[index]!.name).toBe(`output ${index} name`, outputName);
}
}
}
function vertifyInputsStructure(node: LGraphNode, expectedLength: number) {
expect(node.inputs.length).toBe("inputs length", expectedLength);
expect(node.outputs.length).toBe("outputs length", expectedLength - 1);
verifyInputAndOutputName(node, expectedLength - 1, "+", false);
}
describe("TestContextDynamic", async () => {
let nodeConfig!: LGraphNode;
let nodeCtx!: LGraphNode;
let lastNode: LGraphNode | null = null;
await beforeEach(async () => {
await env.clear();
lastNode = nodeConfig = await env.addNode(NodeTypesString.KSAMPLER_CONFIG);
lastNode = nodeCtx = await env.addNode(NodeTypesString.DYNAMIC_CONTEXT);
nodeConfig.connect(0, nodeCtx, 1); // steps
nodeConfig.connect(2, nodeCtx, 2); // cfg
nodeConfig.connect(4, nodeCtx, 3); // scheduler
nodeConfig.connect(0, nodeCtx, 4); // This is the step.1
nodeConfig.connect(0, nodeCtx, 5); // This is the step.2
nodeCtx.disconnectInput(2);
nodeCtx.disconnectInput(5);
nodeConfig.connect(0, nodeCtx, 6); // This is the step.3
nodeCtx.disconnectInput(6);
await wait();
});
await should("add correct inputs", async () => {
vertifyInputsStructure(nodeCtx, 8);
let i = 0;
verifyInputAndOutputName(nodeCtx, i++, "base_ctx", false);
verifyInputAndOutputName(nodeCtx, i++, "+ steps", true);
verifyInputAndOutputName(nodeCtx, i++, "+ cfg", false);
verifyInputAndOutputName(nodeCtx, i++, "+ scheduler", true);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.1", true);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.2", false);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.3", false);
});
await should("add evaluate correct outputs", async () => {
const displayAny1 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "right"});
const displayAny2 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "under"});
const displayAny3 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "under"});
const displayAny4 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "under"});
nodeCtx.connect(1, displayAny1, 0); // steps
nodeCtx.connect(3, displayAny2, 0); // scheduler
nodeCtx.connect(4, displayAny3, 0); // steps.1
nodeCtx.connect(6, displayAny4, 0); // steps.3 (unlinked)
await env.queuePrompt();
expect(displayAny1.widgets![0]!.value).toBe("output 1", 30);
expect(displayAny2.widgets![0]!.value).toBe("output 3", '"normal"');
expect(displayAny3.widgets![0]!.value).toBe("output 4", 30);
expect(displayAny4.widgets![0]!.value).toBe("output 6", "None");
});
await describeRun("Nested", async () => {
let nodeConfig2!: LGraphNode;
let nodeCtx2!: LGraphNode;
await beforeEach(async () => {
nodeConfig2 = await env.addNode(NodeTypesString.KSAMPLER_CONFIG, {placement: "start"});
nodeConfig2.widgets = nodeConfig2.widgets || [];
nodeConfig2.widgets[0]!.value = 111;
nodeConfig2.widgets[2]!.value = 11.1;
nodeCtx2 = await env.addNode(NodeTypesString.DYNAMIC_CONTEXT, {placement: "right"});
nodeConfig2.connect(0, nodeCtx2, 1); // steps
nodeConfig2.connect(2, nodeCtx2, 2); // cfg
nodeConfig2.connect(3, nodeCtx2, 3); // sampler
nodeConfig2.connect(2, nodeCtx2, 4); // This is the cfg.1
nodeConfig2.connect(0, nodeCtx2, 5); // This is the steps.1
nodeCtx2.disconnectInput(2);
nodeCtx2.disconnectInput(5);
nodeConfig2.connect(2, nodeCtx2, 6); // This is the cfg.2
nodeCtx2.disconnectInput(6);
await wait();
});
await should("disallow context node to be connected to non-first spot.", async () => {
// Connect to first node.
let expectedInputs = 8;
nodeCtx2.connect(0, nodeCtx, expectedInputs - 1);
console.log(nodeCtx.inputs);
vertifyInputsStructure(nodeCtx, expectedInputs);
verifyInputAndOutputName(nodeCtx, 0, "base_ctx", false);
verifyInputAndOutputName(nodeCtx, nodeCtx.inputs.length - 1, null, false);
nodeCtx2.connect(0, nodeCtx, 0);
expectedInputs = 14;
vertifyInputsStructure(nodeCtx, expectedInputs);
verifyInputAndOutputName(nodeCtx, 0, "base_ctx", true);
verifyInputAndOutputName(nodeCtx, expectedInputs - 1, null, false);
});
await should("add inputs from connected above owned.", async () => {
// Connect to first node.
nodeCtx2.connect(0, nodeCtx, 0);
let expectedInputs = 14;
vertifyInputsStructure(nodeCtx, expectedInputs);
let i = 0;
verifyInputAndOutputName(nodeCtx, i++, "base_ctx", true);
verifyInputAndOutputName(nodeCtx, i++, "steps", false);
verifyInputAndOutputName(nodeCtx, i++, "cfg", false);
verifyInputAndOutputName(nodeCtx, i++, "sampler", false);
verifyInputAndOutputName(nodeCtx, i++, "cfg.1", false);
verifyInputAndOutputName(nodeCtx, i++, "steps.1", false);
verifyInputAndOutputName(nodeCtx, i++, "cfg.2", false);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.2", true);
verifyInputAndOutputName(nodeCtx, i++, "+ cfg.3", false);
verifyInputAndOutputName(nodeCtx, i++, "+ scheduler", true);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.3", true);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.4", false);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.5", false);
verifyInputAndOutputName(nodeCtx, i++, "+", false);
});
await should("add then remove inputs when disconnected.", async () => {
// Connect to first node.
nodeCtx2.connect(0, nodeCtx, 0);
let expectedInputs = 14;
expect(nodeCtx.inputs.length).toBe("inputs length", expectedInputs);
expect(nodeCtx.outputs.length).toBe("outputs length", expectedInputs - 1);
nodeCtx.disconnectInput(0);
expectedInputs = 8;
expect(nodeCtx.inputs.length).toBe("inputs length", expectedInputs);
expect(nodeCtx.outputs.length).toBe("outputs length", expectedInputs - 1);
let i = 0;
verifyInputAndOutputName(nodeCtx, i++, "base_ctx", false);
verifyInputAndOutputName(nodeCtx, i++, "+ steps", true);
verifyInputAndOutputName(nodeCtx, i++, "+ cfg", false);
verifyInputAndOutputName(nodeCtx, i++, "+ scheduler", true);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.1", true);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.2", false);
verifyInputAndOutputName(nodeCtx, i++, "+ steps.3", false);
verifyInputAndOutputName(nodeCtx, i++, "+", false);
});
});
});

View File

@@ -0,0 +1,103 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {NodeTypesString} from "../constants";
import {ComfyUITestEnvironment} from "../testing/comfyui_env";
import {describe, should, beforeEach, expect, describeRun} from "../testing/runner.js";
import {pasteImageToLoadImageNode, PNG_1x1, PNG_1x2, PNG_2x1} from "../testing/utils_test.js";
const env = new ComfyUITestEnvironment();
describe("TestImageOrLatentSize", async () => {
await beforeEach(async () => {
await env.clear();
});
await describeRun("LoadImage", async () => {
let imageNode: LGraphNode;
let displayAnyW: LGraphNode;
let displayAnyH: LGraphNode;
await beforeEach(async () => {
await env.clear();
imageNode = await env.addNode("LoadImage");
const sizeNode = await env.addNode(NodeTypesString.IMAGE_OR_LATENT_SIZE);
displayAnyW = await env.addNode(NodeTypesString.DISPLAY_ANY);
displayAnyH = await env.addNode(NodeTypesString.DISPLAY_ANY);
imageNode.connect(0, sizeNode, 0);
sizeNode.connect(0, displayAnyW, 0);
sizeNode.connect(1, displayAnyH, 0);
await env.wait();
});
await should("get correct size for a 1x1 image", async () => {
await pasteImageToLoadImageNode(env, PNG_1x1, imageNode);
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 1);
expect(displayAnyH.widgets![0]!.value).toBe("height", 1);
});
await should("get correct size for a 1x2 image", async () => {
await pasteImageToLoadImageNode(env, PNG_1x2, imageNode);
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 1);
expect(displayAnyH.widgets![0]!.value).toBe("height", 2);
});
await should("get correct size for a 2x1 image", async () => {
await pasteImageToLoadImageNode(env, PNG_2x1, imageNode);
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 2);
expect(displayAnyH.widgets![0]!.value).toBe("height", 1);
});
});
await describeRun("Latent", async () => {
let latentNode: LGraphNode;
let displayAnyW: LGraphNode;
let displayAnyH: LGraphNode;
await beforeEach(async () => {
await env.clear();
latentNode = await env.addNode("EmptyLatentImage");
const sizeNode = await env.addNode(NodeTypesString.IMAGE_OR_LATENT_SIZE);
displayAnyW = await env.addNode(NodeTypesString.DISPLAY_ANY);
displayAnyH = await env.addNode(NodeTypesString.DISPLAY_ANY);
latentNode.connect(0, sizeNode, 0);
sizeNode.connect(0, displayAnyW, 0);
sizeNode.connect(1, displayAnyH, 0);
await env.wait();
latentNode.widgets![0]!.value = 16; // Width
latentNode.widgets![1]!.value = 16; // Height
latentNode.widgets![2]!.value = 1; // Batch
await env.wait();
});
await should("get correct size for a 16x16 latent", async () => {
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 16);
expect(displayAnyH.widgets![0]!.value).toBe("height", 16);
});
await should("get correct size for a 16x32 latent", async () => {
latentNode.widgets![1]!.value = 32;
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 16);
expect(displayAnyH.widgets![0]!.value).toBe("height", 32);
});
await should("get correct size for a 32x16 image", async () => {
latentNode.widgets![0]!.value = 32;
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 32);
expect(displayAnyH.widgets![0]!.value).toBe("height", 16);
});
await should("get correct size with a batch", async () => {
latentNode.widgets![0]!.value = 32;
latentNode.widgets![2]!.value = 2;
await env.queuePrompt();
expect(displayAnyW.widgets![0]!.value).toBe("width", 32);
expect(displayAnyH.widgets![0]!.value).toBe("height", 16);
});
});
});

View File

@@ -0,0 +1,162 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {NodeTypesString} from "../constants";
import {ComfyUITestEnvironment} from "../testing/comfyui_env";
import {describe, should, beforeEach, expect, describeRun} from "../testing/runner.js";
import {pasteImageToLoadImageNode, PNG_1x1, PNG_1x2, PNG_2x1} from "../testing/utils_test.js";
const env = new ComfyUITestEnvironment();
function setPowerPuterValue(node: LGraphNode, outputType: string, value: string) {
// Strip as much whitespace on first non-empty line from all lines.
if (value.includes("\n")) {
value = value.replace(/^\n/gm, "");
const strip = value.match(/^(.*?)\S/)?.[1]?.length;
if (strip) {
value = value.replace(new RegExp(`^.{${strip}}`, "mg"), "");
}
}
node.widgets![1]!.value = value;
node.widgets![0]!.value = outputType;
}
describe("TestPowerPuter", async () => {
let powerPuter!: LGraphNode;
let displayAny!: LGraphNode;
await beforeEach(async () => {
await env.clear();
powerPuter = await env.addNode(NodeTypesString.POWER_PUTER);
displayAny = await env.addNode(NodeTypesString.DISPLAY_ANY);
powerPuter.connect(0, displayAny, 0);
await env.wait();
});
await should("output constants and concatenation", async () => {
const checks: Array<[string, string, string]> = [
["1", "1", "STRING"],
['"abc"', "abc", "STRING"],
["1 + 2", "3", "STRING"],
['"abc" + "xyz"', "abcxyz", "STRING"],
// INT
["1", "1", "INT"],
["1 + 2", "3", "INT"],
// FLOAT
["1", "1.0", "FLOAT"],
["1.3 + 2.8", "4.1", "FLOAT"],
// BOOLEAN
["1", "True", "BOOLEAN"],
["1 - 1", "False", "BOOLEAN"],
];
for (const data of checks) {
setPowerPuterValue(powerPuter, data[2], data[0]);
await env.queuePrompt();
expect(displayAny.widgets![0]!.value).toBe(data[0], data[1]);
}
});
await should("handle inputs", async () => {
// TODO
});
await should("handle complex inputs", async () => {
// TODO
});
await should("handle a for loop", async () => {
setPowerPuterValue(
powerPuter,
"STRING",
`
a = 0
b = ''
for n in range(4):
a += n
for m in range(2):
b += f'{str(n)}-{str(m)}.'
f'a:{a} b:{b}'
`,
);
await env.queuePrompt();
expect(displayAny.widgets![0]!.value).toBe("a:6 b:0-0.0-1.1-0.1-1.2-0.2-1.3-0.3-1.");
});
await should("handle assigning with a subscript slice", async () => {
setPowerPuterValue(
powerPuter,
"STRING",
`
a = [1,2,0]
a[a[2]] = 3
tuple(a)
`,
);
await env.queuePrompt();
expect(displayAny.widgets![0]!.value).toBe("(3, 2, 0)");
});
await should("handle aug assigning with a subscript slice", async () => {
setPowerPuterValue(
powerPuter,
"STRING",
`
a = [1,2,0]
a[a[2]] += 3
tuple(a)
`,
);
await env.queuePrompt();
expect(displayAny.widgets![0]!.value).toBe("(4, 2, 0)");
});
await should("disallow calls to some methods", async () => {
const imageNode = await pasteImageToLoadImageNode(env);
imageNode.connect(0, powerPuter, 0);
setPowerPuterValue(
powerPuter,
"STRING",
`a.numpy().tofile('/tmp/test')
`,
);
await env.queuePrompt();
// Check to see if there's an error.
expect(document.querySelector(".p-dialog-mask .p-card-body")!.textContent).toContain(
"error message",
"Disallowed access to \"tofile\" for type <class 'numpy.ndarray'>",
);
(document.querySelector(".p-dialog-mask .p-dialog-close-button")! as HTMLButtonElement).click();
});
await should("handle boolean operators correctly", async () => {
const checks: Array<[string, string, string, ('toMatchJson'|'toBe')?]> = [
// And operator all success
["1 and 42", "42", "STRING"],
["True and [42]", "[42]", "STRING", "toMatchJson"],
["a = 42\nTrue and [a]", "[42]", "STRING", "toMatchJson"],
["1 and 3 and True and [1] and 42", "42", "STRING"],
// And operator w/ a failure
["1 and 3 and True and [] and 42", "[]", "STRING", "toMatchJson"],
["1 and 0 and True and [] and 42", "0", "STRING"],
["1 and 2 and False and [] and 42", "False", "STRING"],
["b = None\n1 and 2 and True and b and 42", "None", "STRING"],
// Or operator
["1 or 42", "1", "STRING"],
["0 or 42", "42", "STRING"],
["0 or None or False or [] or 42", "42", "STRING"],
["b=42\n0 or None or False or [] or b", "42", "STRING"],
["b=42\n0 or None or False or [b] or b", "[42]", "STRING", "toMatchJson"],
["b=42\n0 or None or True or [b] or b", "True", "STRING"],
// Mix
["1 and 2 and 0 or 5", "5", "STRING"],
["None and 1 or True", "True", "STRING"],
["0 or False and True", "False", "STRING"],
];
for (const data of checks) {
setPowerPuterValue(powerPuter, data[2], data[0]);
await env.queuePrompt();
expect(displayAny.widgets![0]!.value)[data[3] || 'toBe'](data[0], data[1]);
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
import type {LGraphCanvas as TLGraphCanvas, Vector2} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
function binarySearch(max: number, getValue: (n: number) => number, match: number) {
let min = 0;
while (min <= max) {
let guess = Math.floor((min + max) / 2);
const compareVal = getValue(guess);
if (compareVal === match) return guess;
if (compareVal < match) min = guess + 1;
else max = guess - 1;
}
return max;
}
/**
* Fits a string against a max width for a ctx. Font should be defined on ctx beforehand.
*/
export function fitString(ctx: CanvasRenderingContext2D, str: string, maxWidth: number) {
let width = ctx.measureText(str).width;
const ellipsis = "…";
const ellipsisWidth = measureText(ctx, ellipsis);
if (width <= maxWidth || width <= ellipsisWidth) {
return str;
}
const index = binarySearch(
str.length,
(guess) => measureText(ctx, str.substring(0, guess)),
maxWidth - ellipsisWidth,
);
return str.substring(0, index) + ellipsis;
}
/** Measures the width of text for a canvas context. */
export function measureText(ctx: CanvasRenderingContext2D, str: string) {
return ctx.measureText(str).width;
}
export type WidgetRenderingOptionsPart = {
type?: "toggle" | "custom";
margin?: number;
fillStyle?: string;
strokeStyle?: string;
lowQuality?: boolean;
draw?(ctx: CanvasRenderingContext2D, x: number, lowQuality: boolean): number;
};
type WidgetRenderingOptions = {
size: [number, number];
pos: [number, number];
borderRadius?: number;
colorStroke?: string;
colorBackground?: string;
};
export function isLowQuality() {
const canvas = app.canvas as TLGraphCanvas;
return (canvas.ds?.scale || 1) <= 0.5;
}
export function drawNodeWidget(ctx: CanvasRenderingContext2D, options: WidgetRenderingOptions) {
const lowQuality = isLowQuality();
const data = {
width: options.size[0],
height: options.size[1],
posY: options.pos[1],
lowQuality,
margin: 15,
colorOutline: LiteGraph.WIDGET_OUTLINE_COLOR,
colorBackground: LiteGraph.WIDGET_BGCOLOR,
colorText: LiteGraph.WIDGET_TEXT_COLOR,
colorTextSecondary: LiteGraph.WIDGET_SECONDARY_TEXT_COLOR,
};
// Draw background.
ctx.strokeStyle = options.colorStroke || data.colorOutline;
ctx.fillStyle = options.colorBackground || data.colorBackground;
ctx.beginPath();
ctx.roundRect(
data.margin,
data.posY,
data.width - data.margin * 2,
data.height,
lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5],
);
ctx.fill();
if (!lowQuality) {
ctx.stroke();
}
return data;
}
/** Draws a rounded rectangle. */
export function drawRoundedRectangle(
ctx: CanvasRenderingContext2D,
options: WidgetRenderingOptions,
) {
const lowQuality = isLowQuality();
options = {...options};
ctx.save();
ctx.strokeStyle = options.colorStroke || LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fillStyle = options.colorBackground || LiteGraph.WIDGET_BGCOLOR;
ctx.beginPath();
ctx.roundRect(
...options.pos,
...options.size,
lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5],
);
ctx.fill();
!lowQuality && ctx.stroke();
ctx.restore();
}
type DrawNumberWidgetPartOptions = {
posX: number;
posY: number;
height: number;
value: number;
direction?: 1 | -1;
textColor?: string;
};
/**
* 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, textColor} = 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";
const oldTextcolor = ctx.fillStyle;
if (textColor) {
ctx.fillStyle = textColor;
}
ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY);
ctx.fillStyle = oldTextcolor;
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];
}
export function drawInfoIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number = 12,
) {
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, size, size, [size * 0.1]);
ctx.fillStyle = "#2f82ec";
ctx.strokeStyle = "#0f2a5e";
ctx.fill();
// ctx.stroke();
ctx.strokeStyle = "#FFF";
ctx.lineWidth = 2;
// ctx.lineCap = 'round';
const midX = x + size / 2;
const serifSize = size * 0.175;
ctx.stroke(
new Path2D(`
M ${midX} ${y + size * 0.15}
v 2
M ${midX - serifSize} ${y + size * 0.45}
h ${serifSize}
v ${size * 0.325}
h ${serifSize}
h -${serifSize * 2}
`),
);
ctx.restore();
}
export function drawPlusIcon(
ctx: CanvasRenderingContext2D,
x: number,
midY: number,
size: number = 12,
) {
ctx.save();
const s = size / 3;
const plus = new Path2D(`
M ${x} ${midY + s / 2}
v-${s} h${s} v-${s} h${s}
v${s} h${s} v${s} h-${s}
v${s} h-${s} v-${s} h-${s}
z
`);
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.fillStyle = "#3a3";
ctx.strokeStyle = "#383";
ctx.fill(plus);
ctx.stroke(plus);
ctx.restore();
}
/**
* Draws a better button.
*/
export function drawWidgetButton(
ctx: CanvasRenderingContext2D,
options: WidgetRenderingOptions,
text: string | null = null,
isMouseDownedAndOver: boolean = false,
) {
const borderRadius = isLowQuality() ? 0 : (options.borderRadius ?? 4);
ctx.save();
if (!isLowQuality() && !isMouseDownedAndOver) {
drawRoundedRectangle(ctx, {
size: [options.size[0] - 2, options.size[1]],
pos: [options.pos[0] + 1, options.pos[1] + 1],
borderRadius,
colorBackground: "#000000aa",
colorStroke: "#000000aa",
});
}
// BG
drawRoundedRectangle(ctx, {
size: options.size,
pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
borderRadius,
colorBackground: isMouseDownedAndOver ? "#444" : LiteGraph.WIDGET_BGCOLOR,
colorStroke: "transparent",
});
if (isLowQuality()) {
ctx.restore();
return;
}
if (!isMouseDownedAndOver) {
// Shadow
drawRoundedRectangle(ctx, {
size: [options.size[0] - 0.75, options.size[1] - 0.75],
pos: options.pos,
borderRadius: borderRadius - 0.5,
colorBackground: "transparent",
colorStroke: "#00000044",
});
// Highlight
drawRoundedRectangle(ctx, {
size: [options.size[0] - 0.75, options.size[1] - 0.75],
pos: [options.pos[0] + 0.75, options.pos[1] + 0.75],
borderRadius: borderRadius - 0.5,
colorBackground: "transparent",
colorStroke: "#ffffff11",
});
}
// Stroke
drawRoundedRectangle(ctx, {
size: options.size,
pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
borderRadius,
colorBackground: "transparent",
});
if (!isLowQuality() && text) {
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText(
text,
options.size[0] / 2,
options.pos[1] + options.size[1] / 2 + (isMouseDownedAndOver ? 1 : 0),
);
}
ctx.restore();
}

View File

@@ -0,0 +1,294 @@
/**
* [🤮] ComfyUI started deprecating the use of their legacy JavaScript files. These are ports/shims
* since we relied on them at one point.
*
* TODO: Should probably remove these all together at some point.
*/
import {app} from "scripts/app.js";
import type {INodeInputSlot, INodeOutputSlot, InputSpec, LGraphNode} from "@comfyorg/frontend";
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts#L462 */
interface PrimitiveNode extends LGraphNode {
recreateWidget(): void;
onLastDisconnect(): void;
}
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/renderer/utils/nodeTypeGuards.ts */
function isPrimitiveNode(node: LGraphNode): node is PrimitiveNode {
return node.type === "PrimitiveNode";
}
/**
* CONFIG and GET_CONFIG in https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/services/litegraphService.ts
* are not accessible publicly, so we need to look at a slot.widget's symbols and check to see if it
* matches rather than access it directly. Cool...
*/
function getWidgetGetConfigSymbols(slot: INodeOutputSlot | INodeInputSlot): {
CONFIG?: symbol;
GET_CONFIG?: symbol;
} {
const widget = slot?.widget;
if (!widget) return {};
const syms = Object.getOwnPropertySymbols(widget || {});
for (const sym of syms) {
const symVal = widget![sym];
const isGetConfig = typeof symVal === "function";
let maybeCfg = isGetConfig ? symVal() : symVal;
if (
Array.isArray(maybeCfg) &&
maybeCfg.length >= 2 &&
typeof maybeCfg[0] === "string" &&
(maybeCfg[0] === "*" || typeof maybeCfg[1]?.type === "string")
) {
return isGetConfig ? {GET_CONFIG: sym} : {CONFIG: sym};
}
}
return {};
}
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts
*/
export function getWidgetConfig(slot: INodeOutputSlot | INodeInputSlot): InputSpec {
const configSyms = getWidgetGetConfigSymbols(slot);
const widget = slot.widget || ({} as any);
return (
(configSyms.CONFIG && widget[configSyms.CONFIG]) ??
(configSyms.GET_CONFIG && widget[configSyms.GET_CONFIG]?.()) ?? ["*", {}]
);
}
/**
* This is lossy, since we don't have access to GET_CONFIG Symbol, we cannot accurately set it. As a
* best-chance we can look for a function that seems to return a
*
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts
*/
export function setWidgetConfig(
slot: INodeOutputSlot | INodeInputSlot | undefined,
config: InputSpec,
) {
if (!slot?.widget) return;
if (config) {
const configSyms = getWidgetGetConfigSymbols(slot);
const widget = slot.widget || ({} as any);
if (configSyms.GET_CONFIG) {
widget[configSyms.GET_CONFIG] = () => config;
} else if (configSyms.CONFIG) {
widget[configSyms.CONFIG] = config;
} else {
console.error(
"Cannot set widget Config. This is due to ComfyUI removing the ability to call legacy " +
"JavaScript APIs that are now deprecated without new, supported APIs. It's possible " +
"some things in rgthree-comfy do not work correctly. If you see this, please file a bug.",
);
}
} else {
delete slot.widget;
}
if ("link" in slot) {
const link = app.graph.links[(slot as INodeInputSlot)?.link ?? -1];
if (link) {
const originNode = app.graph.getNodeById(link.origin_id);
if (originNode && isPrimitiveNode(originNode)) {
if (config) {
originNode.recreateWidget();
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0);
originNode.onLastDisconnect();
}
}
}
}
}
/**
* A slimmed-down version of `mergeIfValid` for only what was needed in rgthree-comfy.
*
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts
*/
export function mergeIfValid(
output: INodeOutputSlot | INodeInputSlot,
config2: InputSpec,
): InputSpec[1] | null {
const config1 = getWidgetConfig(output);
const customSpec = mergeInputSpec(config1, config2);
if (customSpec) {
setWidgetConfig(output, customSpec);
}
return customSpec?.[1] ?? null;
}
/**
* Merges two input specs.
*
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts
*/
const mergeInputSpec = (spec1: InputSpec, spec2: InputSpec): InputSpec | null => {
const type1 = getInputSpecType(spec1);
const type2 = getInputSpecType(spec2);
if (type1 !== type2) {
return null;
}
if (isIntInputSpec(spec1) || isFloatInputSpec(spec1)) {
return mergeNumericInputSpec(spec1, spec2 as typeof spec1);
}
if (isComboInputSpec(spec1)) {
return mergeComboInputSpec(spec1, spec2 as typeof spec1);
}
return mergeCommonInputSpec(spec1, spec2);
};
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function getInputSpecType(inputSpec: InputSpec): string {
return isComboInputSpec(inputSpec) ? "COMBO" : inputSpec[0];
}
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function isComboInputSpecV1(inputSpec: InputSpec) {
return Array.isArray(inputSpec[0]);
}
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function isIntInputSpec(inputSpec: InputSpec) {
return inputSpec[0] === "INT";
}
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function isFloatInputSpec(inputSpec: InputSpec) {
return inputSpec[0] === "FLOAT";
}
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function isComboInputSpecV2(inputSpec: InputSpec) {
return inputSpec[0] === "COMBO";
}
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function isComboInputSpec(inputSpec: InputSpec) {
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec);
}
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
const getRange = (options: any) => {
const min = options.min ?? -Infinity;
const max = options.max ?? Infinity;
return {min, max};
};
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
const mergeNumericInputSpec = <T extends any>(spec1: any, spec2: any): T | null => {
const type = spec1[0];
const options1 = spec1[1] ?? {};
const options2 = spec2[1] ?? {};
const range1 = getRange(options1);
const range2 = getRange(options2);
// If the ranges do not overlap, return null
if (range1.min > range2.max || range1.max < range2.min) {
return null;
}
const step1 = options1.step ?? 1;
const step2 = options2.step ?? 1;
const mergedOptions = {
// Take intersection of ranges
min: Math.max(range1.min, range2.min),
max: Math.min(range1.max, range2.max),
step: lcm(step1, step2),
};
return mergeCommonInputSpec(
[type, {...options1, ...mergedOptions}] as T,
[type, {...options2, ...mergedOptions}] as T,
);
};
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
const mergeComboInputSpec = <T extends any>(spec1: any, spec2: any): T | null => {
const options1 = spec1[1] ?? {};
const options2 = spec2[1] ?? {};
const comboOptions1 = getComboSpecComboOptions(spec1);
const comboOptions2 = getComboSpecComboOptions(spec2);
const intersection = comboOptions1.filter((value) => comboOptions2.includes(value));
// If the intersection is empty, return null
if (intersection.length === 0) {
return null;
}
return mergeCommonInputSpec(
["COMBO", {...options1, options: intersection}] as T,
["COMBO", {...options2, options: intersection}] as T,
);
};
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
const mergeCommonInputSpec = <T extends InputSpec>(spec1: T, spec2: T): T | null => {
const type = getInputSpecType(spec1);
const options1 = spec1[1] ?? {};
const options2 = spec2[1] ?? {};
const compareKeys = [...new Set([...Object.keys(options1), ...Object.keys(options2)])].filter(
(key: string) => !IGNORE_KEYS.has(key),
);
const mergeIsValid = compareKeys.every((key: string) => {
const value1 = options1[key];
const value2 = options2[key];
return value1 === value2 || (value1 == null && value2 == null);
});
return mergeIsValid ? ([type, {...options1, ...options2}] as T) : null;
};
/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
const IGNORE_KEYS = new Set<string>([
"default",
"forceInput",
"defaultInput",
"control_after_generate",
"multiline",
"tooltip",
"dynamicPrompts",
]);
/**
* Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
*/
function getComboSpecComboOptions(inputSpec: any): (number | string)[] {
return (isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? [];
}
/** Taken from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/mathUtil.ts */
const lcm = (a: number, b: number): number => {
return Math.abs(a * b) / gcd(a, b);
};
/** Taken from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/mathUtil.ts */
const gcd = (a: number, b: number): number => {
return b === 0 ? a : gcd(b, a % b);
};

View File

@@ -0,0 +1,18 @@
import type {LGraphNode} from "@comfyorg/frontend";
import { RgthreeBaseNode } from "./base_node";
/** Removes all inputs from the end. */
export function removeUnusedInputsFromEnd(node: LGraphNode, minNumber = 1, nameMatch?: RegExp) {
// No need to remove inputs from nodes that have been removed. This can happen because we may
// have debounced cleanup tasks.
if ((node as RgthreeBaseNode).removed) return;
for (let i = node.inputs.length - 1; i >= minNumber; i--) {
if (!node.inputs[i]?.link) {
if (!nameMatch || nameMatch.test(node.inputs[i]!.name)) {
node.removeInput(i);
}
continue;
}
break;
}
}

View File

@@ -0,0 +1,98 @@
import type {
LGraphCanvas as TLGraphCanvas,
LGraphNode,
ContextMenu,
IContextMenuValue,
IBaseWidget,
IContextMenuOptions,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {rgthreeApi} from "rgthree/common/rgthree_api.js";
const PASS_THROUGH = function <T extends any, I extends any>(item: T) {
return item as T;
};
/**
* Shows a lora chooser context menu.
*/
export async function showLoraChooser(
event: PointerEvent | MouseEvent,
callback: IContextMenuOptions["callback"],
parentMenu?: ContextMenu | null,
loras?: string[],
) {
const canvas = app.canvas as TLGraphCanvas;
if (!loras) {
loras = ["None", ...(await rgthreeApi.getLoras().then((loras) => loras.map((l) => l.file)))];
}
new LiteGraph.ContextMenu(loras, {
event: event,
parentMenu: parentMenu != null ? parentMenu : undefined,
title: "Choose a lora",
scale: Math.max(1, canvas.ds?.scale ?? 1),
className: "dark",
callback,
});
}
/**
* Shows a context menu chooser of nodes.
*
* @param mapFn The function used to map each node to the context menu item. If null is returned
* it will be filtered out (rather than use a separate filter method).
*/
export function showNodesChooser<T extends IContextMenuValue>(
event: PointerEvent | MouseEvent,
mapFn: (n: LGraphNode) => T | null,
callback: IContextMenuOptions["callback"],
parentMenu?: ContextMenu,
) {
const canvas = app.canvas as TLGraphCanvas;
const nodesOptions: T[] = (app.graph._nodes as LGraphNode[])
.map(mapFn)
.filter((e): e is NonNullable<any> => e != null);
nodesOptions.sort((a: any, b: any) => {
return a.value - b.value;
});
new LiteGraph.ContextMenu(nodesOptions, {
event: event,
parentMenu,
title: "Choose a node id",
scale: Math.max(1, canvas.ds?.scale ?? 1),
className: "dark",
callback,
});
}
/**
* Shows a conmtext menu chooser for a specific node.
*
* @param mapFn The function used to map each node to the context menu item. If null is returned
* it will be filtered out (rather than use a separate filter method).
*/
export function showWidgetsChooser<T extends IContextMenuValue>(
event: PointerEvent | MouseEvent,
node: LGraphNode,
mapFn: (n: IBaseWidget) => T | null,
callback: IContextMenuOptions["callback"],
parentMenu?: ContextMenu,
) {
const options: T[] = (node.widgets || [])
.map(mapFn)
.filter((e): e is NonNullable<any> => e != null);
if (options.length) {
const canvas = app.canvas as TLGraphCanvas;
new LiteGraph.ContextMenu(options, {
event,
parentMenu,
title: "Choose an input/widget",
scale: Math.max(1, canvas.ds?.scale ?? 1),
className: "dark",
callback,
});
}
}

View File

@@ -0,0 +1,505 @@
import type {
LGraphNode,
LGraphCanvas as TLGraphCanvas,
Vector2,
ICustomWidget,
IWidgetOptions,
CanvasPointerEvent,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {drawNodeWidget, drawWidgetButton, fitString, isLowQuality} from "./utils_canvas.js";
type Vector4 = [number, number, number, number];
/**
* Draws a label on teft, and a value on the right, ellipsizing when out of space.
*/
export function drawLabelAndValue(
ctx: CanvasRenderingContext2D,
label: string,
value: string,
width: number,
posY: number,
height: number,
options?: {offsetLeft: number},
) {
const outerMargin = 15;
const innerMargin = 10;
const midY = posY + height / 2;
ctx.save();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
const labelX = outerMargin + innerMargin + (options?.offsetLeft ?? 0);
ctx.fillText(label, labelX, midY);
const valueXLeft = labelX + ctx.measureText(label).width + 7;
const valueXRight = width - (outerMargin + innerMargin);
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "right";
ctx.fillText(fitString(ctx, value, valueXRight - valueXLeft), valueXRight, midY);
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: CanvasPointerEvent,
pos: Vector2,
node: LGraphNode,
bounds: RgthreeBaseWidgetBounds,
): boolean | void;
onUp?(
event: CanvasPointerEvent,
pos: Vector2,
node: LGraphNode,
bounds: RgthreeBaseWidgetBounds,
): boolean | void;
onMove?(
event: CanvasPointerEvent,
pos: Vector2,
node: LGraphNode,
bounds: RgthreeBaseWidgetBounds,
): boolean | void;
onClick?(
event: CanvasPointerEvent,
pos: Vector2,
node: LGraphNode,
bounds: RgthreeBaseWidgetBounds,
): boolean | void;
data?: any;
wasMouseClickedAndIsOver?: boolean;
};
export type RgthreeBaseHitAreas<Keys extends string> = {
[K in Keys]: RgthreeBaseWidgetBounds;
};
type NotArray<T> = T extends Array<any> ? never : T;
/**
* A base widget that handles mouse events more properly.
*/
export abstract class RgthreeBaseWidget<V extends ICustomWidget["value"]> implements ICustomWidget {
// Needed here b/c it was added to ComfyUI's types for IBaseWidget. No idea what they use it for,
// or why it's only boolean.
[symbol: symbol]: boolean;
// We don't want our value to be an array as a widget will be serialized as an "input" for the API
// which uses an array value to represent a link. To keep things simpler, we'll avoid using an
// array at all.
abstract value: NotArray<V>;
type: ICustomWidget["type"] = "custom";
name: string;
options: IWidgetOptions = {};
y: number = 0;
last_y: number = 0;
protected mouseDowned: Vector2 | null = null;
protected isMouseDownedAndOver: boolean = false;
// protected hitAreas: {[key: string]: RgthreeBaseWidgetBounds} = {};
protected readonly hitAreas: RgthreeBaseHitAreas<any> = {};
private downedHitAreasForMove: RgthreeBaseWidgetBounds[] = [];
private downedHitAreasForClick: RgthreeBaseWidgetBounds[] = [];
constructor(name: string) {
this.name = name;
}
serializeValue(node: LGraphNode, index: number): Promise<V> | V {
return this.value;
}
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: CanvasPointerEvent, pos: Vector2, node: LGraphNode) {
const canvas = app.canvas as TLGraphCanvas;
if (event.type == "pointerdown") {
this.mouseDowned = [...pos] as Vector2;
this.isMouseDownedAndOver = true;
this.downedHitAreasForMove.length = 0;
this.downedHitAreasForClick.length = 0;
// Loop over out bounds data and call any specifics.
let anyHandled = false;
for (const part of Object.values(this.hitAreas)) {
if (this.clickWasWithinBounds(pos, part.bounds)) {
if (part.onMove) {
this.downedHitAreasForMove.push(part);
}
if (part.onClick) {
this.downedHitAreasForClick.push(part);
}
if (part.onDown) {
const thisHandled = part.onDown.apply(this, [event, pos, node, part]);
anyHandled = anyHandled || thisHandled == true;
}
part.wasMouseClickedAndIsOver = 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;
const wasMouseDownedAndOver = this.isMouseDownedAndOver;
this.cancelMouseDown();
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, part]);
anyHandled = anyHandled || thisHandled == true;
}
part.wasMouseClickedAndIsOver = false;
}
for (const part of this.downedHitAreasForClick) {
if (this.clickWasWithinBounds(pos, part.bounds)) {
const thisHandled = part.onClick!.apply(this, [event, pos, node, part]);
anyHandled = anyHandled || thisHandled == true;
}
}
this.downedHitAreasForClick.length = 0;
if (wasMouseDownedAndOver) {
const thisHandled = this.onMouseClick(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).
if (event.type == "pointermove") {
this.isMouseDownedAndOver = !!this.mouseDowned;
// If we've moved off the button while pressing, then consider us no longer pressing.
if (
this.mouseDowned &&
(pos[0] < 15 ||
pos[0] > node.size[0] - 15 ||
pos[1] < this.last_y ||
pos[1] > this.last_y + LiteGraph.NODE_WIDGET_HEIGHT)
) {
this.isMouseDownedAndOver = false;
}
for (const part of Object.values(this.hitAreas)) {
if (this.downedHitAreasForMove.includes(part)) {
part.onMove!.apply(this, [event, pos, node, part]);
}
if (this.downedHitAreasForClick.includes(part)) {
part.wasMouseClickedAndIsOver = this.clickWasWithinBounds(pos, part.bounds);
}
}
return this.onMouseMove(event, pos, node) ?? true;
}
return false;
}
/** Sometimes we want to cancel a mouse down, so that an up/move aren't fired. */
cancelMouseDown() {
this.mouseDowned = null;
this.isMouseDownedAndOver = false;
this.downedHitAreasForMove.length = 0;
}
/** An event that fires when the pointer is pressed down (once). */
onMouseDown(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
return;
}
/**
* An event that fires when the pointer is let go. Only fires if this was the widget that was
* originally pressed down.
*/
onMouseUp(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
return;
}
/**
* An event that fires when the pointer is let go _over the widget_ and when the widget that was
* originally pressed down.
*/
onMouseClick(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
return;
}
/**
* An event that fires when the pointer is moving after pressing down. Will fire both on and off
* of the widget. Check `isMouseDownedAndOver` to determine if the mouse is currently over the
* widget or not.
*/
onMouseMove(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
return;
}
}
/**
* A better implementation of the LiteGraph button widget.
*/
export class RgthreeBetterButtonWidget extends RgthreeBaseWidget<string> {
override readonly type = "custom";
value: string = "";
label: string = "";
mouseClickCallback: (event: CanvasPointerEvent, pos: Vector2, node: LGraphNode) => boolean | void;
constructor(
name: string,
mouseClickCallback: (
event: CanvasPointerEvent,
pos: Vector2,
node: LGraphNode,
) => boolean | void,
label?: string,
) {
super(name);
this.mouseClickCallback = mouseClickCallback;
this.label = label || name;
}
draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, y: number, height: number) {
drawWidgetButton(
ctx,
{size: [width - 30, height], pos: [15, y]},
this.label,
this.isMouseDownedAndOver,
);
}
override onMouseClick(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode) {
return this.mouseClickCallback(event, pos, node);
}
}
/**
* A better implementation of the LiteGraph text widget, including auto ellipsis.
*/
export class RgthreeBetterTextWidget extends RgthreeBaseWidget<string> {
value: string;
constructor(name: string, value: string) {
super(name);
this.name = name;
this.value = value;
}
draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, y: number, height: number) {
const widgetData = drawNodeWidget(ctx, {size: [width, height], pos: [15, y]});
if (!widgetData.lowQuality) {
drawLabelAndValue(ctx, this.name, this.value, width, y, height);
}
}
override mouse(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean {
const canvas = app.canvas as TLGraphCanvas;
if (event.type == "pointerdown") {
canvas.prompt("Label", this.value, (v: string) => (this.value = v), event);
return true;
}
return false;
}
}
/**
* Options for the Divider Widget.
*/
type RgthreeDividerWidgetOptions = {
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
color: string;
thickness: number;
};
/**
* A divider widget; can also be used as a spacer if fed a 0 thickness.
*/
export class RgthreeDividerWidget extends RgthreeBaseWidget<{}> {
override value = {};
override options = {serialize: false};
override readonly type = "custom";
private readonly widgetOptions: RgthreeDividerWidgetOptions = {
marginTop: 7,
marginBottom: 7,
marginLeft: 15,
marginRight: 15,
color: LiteGraph.WIDGET_OUTLINE_COLOR,
thickness: 1,
};
constructor(widgetOptions?: Partial<RgthreeDividerWidgetOptions>) {
super("divider");
Object.assign(this.widgetOptions, widgetOptions || {});
}
draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, posY: number, h: number) {
if (this.widgetOptions.thickness) {
ctx.strokeStyle = this.widgetOptions.color;
const x = this.widgetOptions.marginLeft;
const y = posY + this.widgetOptions.marginTop;
const w = width - this.widgetOptions.marginLeft - this.widgetOptions.marginRight;
ctx.stroke(new Path2D(`M ${x} ${y} h ${w}`));
}
}
computeSize(width: number): [number, number] {
return [
width,
this.widgetOptions.marginTop + this.widgetOptions.marginBottom + this.widgetOptions.thickness,
];
}
}
/**
* Options for the Label Widget.
*/
export type RgthreeLabelWidgetOptions = {
align?: "left" | "center" | "right";
color?: string;
italic?: boolean;
size?: number;
text?: string | (() => string); // Text, or fall back to the name.
/** A label to put on the right side. */
actionLabel?: "__PLUS_ICON__" | string;
actionCallback?: (event: PointerEvent | CanvasPointerEvent) => void;
};
/**
* A simple label widget, drawn with no background.
*/
export class RgthreeLabelWidget extends RgthreeBaseWidget<string> {
override readonly type = "custom";
override options = {serialize: false};
value = "";
private readonly widgetOptions: RgthreeLabelWidgetOptions = {};
private posY: number = 0;
constructor(name: string, widgetOptions?: RgthreeLabelWidgetOptions) {
super(name);
Object.assign(this.widgetOptions, widgetOptions);
}
update(widgetOptions: RgthreeLabelWidgetOptions) {
Object.assign(this.widgetOptions, widgetOptions);
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
width: number,
posY: number,
height: number,
) {
this.posY = posY;
ctx.save();
let text = this.widgetOptions.text ?? this.name;
if (typeof text === "function") {
text = text();
}
ctx.textAlign = this.widgetOptions.align || "left";
ctx.fillStyle = this.widgetOptions.color || LiteGraph.WIDGET_TEXT_COLOR;
const oldFont = ctx.font;
if (this.widgetOptions.italic) {
ctx.font = "italic " + ctx.font;
}
if (this.widgetOptions.size) {
ctx.font = ctx.font.replace(/\d+px/, `${this.widgetOptions.size}px`);
}
const midY = posY + height / 2;
ctx.textBaseline = "middle";
if (this.widgetOptions.align === "center") {
ctx.fillText(text, node.size[0] / 2, midY);
} else {
ctx.fillText(text, 15, midY);
} // TODO(right);
ctx.font = oldFont;
if (this.widgetOptions.actionLabel === "__PLUS_ICON__") {
const plus = new Path2D(
`M${node.size[0] - 15 - 2} ${posY + 7} v4 h-4 v4 h-4 v-4 h-4 v-4 h4 v-4 h4 v4 h4 z`,
);
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.fillStyle = "#3a3";
ctx.strokeStyle = "#383";
ctx.fill(plus);
ctx.stroke(plus);
}
ctx.restore();
}
override mouse(event: CanvasPointerEvent, nodePos: Vector2, node: LGraphNode): boolean {
if (
event.type !== "pointerdown" ||
isLowQuality() ||
!this.widgetOptions.actionLabel ||
!this.widgetOptions.actionCallback
) {
return false;
}
const pos: Vector2 = [nodePos[0], nodePos[1] - this.posY];
const rightX = node.size[0] - 15;
if (pos[0] > rightX || pos[0] < rightX - 16) {
return false;
}
this.widgetOptions.actionCallback(event);
return true;
}
}
/** An invisible widget. */
export class RgthreeInvisibleWidget<T extends ICustomWidget["value"]> extends RgthreeBaseWidget<T> {
override readonly type = "custom";
value: NotArray<T>;
private serializeValueFn?: (node: LGraphNode, index: number) => Promise<T> | T;
constructor(
name: string,
type: string,
value: NotArray<T>,
serializeValueFn?: (node: LGraphNode, index: number) => Promise<T> | T,
) {
super(name);
// this.type = type;
this.value = value;
this.serializeValueFn = serializeValueFn;
}
draw() {
return;
}
computeSize(width: number): Vector2 {
return [0, 0];
}
override serializeValue(node: LGraphNode, index: number): T | Promise<T> {
return this.serializeValueFn != null
? this.serializeValueFn(node, index)
: super.serializeValue(node, index);
}
}