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
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:
103
custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts
Normal file
103
custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
504
custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts
Normal file
504
custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
366
custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts
Normal file
366
custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
163
custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts
Normal file
163
custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
52
custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts
Normal file
52
custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts
Normal 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];
|
||||
}
|
||||
},
|
||||
});
|
||||
264
custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts
Normal file
264
custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
407
custom_nodes/rgthree-comfy/src_web/comfyui/config.ts
Normal file
407
custom_nodes/rgthree-comfy/src_web/comfyui/config.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
72
custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts
Normal file
72
custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts
Normal 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();
|
||||
}
|
||||
488
custom_nodes/rgthree-comfy/src_web/comfyui/context.ts
Normal file
488
custom_nodes/rgthree-comfy/src_web/comfyui/context.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
436
custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts
Normal file
436
custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts
Normal 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>` : "";
|
||||
}
|
||||
71
custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts
Normal file
71
custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts
Normal 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);
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
});
|
||||
302
custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts
Normal file
302
custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
542
custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts
Normal file
542
custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
480
custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts
Normal file
480
custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
229
custom_nodes/rgthree-comfy/src_web/comfyui/label.ts
Normal file
229
custom_nodes/rgthree-comfy/src_web/comfyui/label.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
156
custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts
Normal file
156
custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts
Normal 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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 [];
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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) `,
|
||||
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) `,
|
||||
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;
|
||||
};
|
||||
},
|
||||
});
|
||||
51
custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts
Normal file
51
custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts
Normal 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];
|
||||
}
|
||||
},
|
||||
});
|
||||
168
custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts
Normal file
168
custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
280
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts
Normal file
280
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
216
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts
Normal file
216
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
137
custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts
Normal file
137
custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
851
custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts
Normal file
851
custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
254
custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts
Normal file
254
custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
48
custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts
Normal file
48
custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
461
custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts
Normal file
461
custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
117
custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts
Normal file
117
custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts
Normal 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];
|
||||
}
|
||||
},
|
||||
});
|
||||
1285
custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts
Normal file
1285
custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts
Normal file
File diff suppressed because it is too large
Load Diff
328
custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss
Normal file
328
custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss
Normal 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%;
|
||||
}
|
||||
1062
custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.ts
Normal file
1062
custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.ts
Normal file
File diff suppressed because it is too large
Load Diff
308
custom_nodes/rgthree-comfy/src_web/comfyui/seed.ts
Normal file
308
custom_nodes/rgthree-comfy/src_web/comfyui/seed.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
167
custom_nodes/rgthree-comfy/src_web/comfyui/testing/runner.ts
Normal file
167
custom_nodes/rgthree-comfy/src_web/comfyui/testing/runner.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
custom_nodes/rgthree-comfy/src_web/comfyui/tests/power_puter.ts
Normal file
162
custom_nodes/rgthree-comfy/src_web/comfyui/tests/power_puter.ts
Normal 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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
1115
custom_nodes/rgthree-comfy/src_web/comfyui/utils.ts
Normal file
1115
custom_nodes/rgthree-comfy/src_web/comfyui/utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
389
custom_nodes/rgthree-comfy/src_web/comfyui/utils_canvas.ts
Normal file
389
custom_nodes/rgthree-comfy/src_web/comfyui/utils_canvas.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
98
custom_nodes/rgthree-comfy/src_web/comfyui/utils_menu.ts
Normal file
98
custom_nodes/rgthree-comfy/src_web/comfyui/utils_menu.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
505
custom_nodes/rgthree-comfy/src_web/comfyui/utils_widgets.ts
Normal file
505
custom_nodes/rgthree-comfy/src_web/comfyui/utils_widgets.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user