keyon857 commited on
Commit
0af1a1a
·
1 Parent(s): 2923771

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +22 -0
  2. ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_multiple_images_workflow.png +3 -0
  3. ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_single_image_workflow.png +3 -0
  4. ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_text_workflow.png +3 -0
  5. ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_video_workflow.png +3 -0
  6. ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00508_.png +3 -0
  7. ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00509_.png +3 -0
  8. comfyui_layerstyle/image/segment_anything_ultra_node.jpg +3 -0
  9. comfyui_layerstyle/py/__pycache__/color_name.cpython-312.pyc +3 -0
  10. comfyui_layerstyle/py/__pycache__/imagefunc.cpython-312.pyc +3 -0
  11. comfyui_layerstyle/workflow/1344x768_redcar.png +3 -0
  12. comfyui_layerstyle/workflow/512x512.png +3 -0
  13. comfyui_layerstyle/workflow/768x1344_beach.png +3 -0
  14. comfyui_layerstyle/workflow/768x1344_dress.png +3 -0
  15. comfyui_layerstyle/workflow/fox_512x512.png +3 -0
  16. comfyui_layerstyle/workflow/girl_dino_1024.png +3 -0
  17. comfyui_tensorrt/readme_images/image1.png +3 -0
  18. comfyui_tensorrt/readme_images/image11.png +3 -0
  19. comfyui_tensorrt/readme_images/image4.png +3 -0
  20. comfyui_tensorrt/readme_images/image6.png +3 -0
  21. rgthree-comfy/py/__pycache__/__init__.cpython-312.pyc +0 -0
  22. rgthree-comfy/py/__pycache__/utils_userdata.cpython-312.pyc +0 -0
  23. rgthree-comfy/py/server/__pycache__/rgthree_server.cpython-312.pyc +0 -0
  24. rgthree-comfy/py/server/__pycache__/routes_config.cpython-312.pyc +0 -0
  25. rgthree-comfy/py/server/__pycache__/routes_model_info.cpython-312.pyc +0 -0
  26. rgthree-comfy/py/server/__pycache__/utils_info.cpython-312.pyc +0 -0
  27. rgthree-comfy/py/server/__pycache__/utils_server.cpython-312.pyc +0 -0
  28. rgthree-comfy/src_web/comfyui/any_switch.ts +101 -0
  29. rgthree-comfy/src_web/comfyui/base_any_input_connected_node.ts +345 -0
  30. rgthree-comfy/src_web/comfyui/base_node.ts +462 -0
  31. rgthree-comfy/src_web/comfyui/base_node_collector.ts +98 -0
  32. rgthree-comfy/src_web/comfyui/base_node_mode_changer.ts +120 -0
  33. rgthree-comfy/src_web/comfyui/base_power_prompt.ts +364 -0
  34. rgthree-comfy/src_web/comfyui/bookmark.ts +163 -0
  35. rgthree-comfy/src_web/comfyui/bypasser.ts +51 -0
  36. rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts +120 -0
  37. rgthree-comfy/src_web/comfyui/config.ts +406 -0
  38. rgthree-comfy/src_web/comfyui/constants.ts +62 -0
  39. rgthree-comfy/src_web/comfyui/context.ts +483 -0
  40. rgthree-comfy/src_web/comfyui/dialog_info.ts +421 -0
  41. rgthree-comfy/src_web/comfyui/display_any.ts +73 -0
  42. rgthree-comfy/src_web/comfyui/dynamic_context.ts +297 -0
  43. rgthree-comfy/src_web/comfyui/dynamic_context_base.ts +237 -0
  44. rgthree-comfy/src_web/comfyui/dynamic_context_switch.ts +207 -0
  45. rgthree-comfy/src_web/comfyui/fast_actions_button.ts +343 -0
  46. rgthree-comfy/src_web/comfyui/fast_groups_bypasser.ts +37 -0
  47. rgthree-comfy/src_web/comfyui/fast_groups_muter.ts +502 -0
  48. rgthree-comfy/src_web/comfyui/feature_group_fast_toggle.ts +304 -0
  49. rgthree-comfy/src_web/comfyui/feature_import_individual_nodes.ts +69 -0
  50. rgthree-comfy/src_web/comfyui/image_comparer.ts +474 -0
.gitattributes CHANGED
@@ -255,3 +255,25 @@ comfyui_layerstyle/workflow/1280x720_seven_person.jpg filter=lfs diff=lfs merge=
255
  comfyui_layerstyle/workflow/1280x720car.jpg filter=lfs diff=lfs merge=lfs -text
256
  comfyui_layerstyle/workflow/1280x768_city.png filter=lfs diff=lfs merge=lfs -text
257
  comfyui_layerstyle/workflow/1344x768_girl2.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  comfyui_layerstyle/workflow/1280x720car.jpg filter=lfs diff=lfs merge=lfs -text
256
  comfyui_layerstyle/workflow/1280x768_city.png filter=lfs diff=lfs merge=lfs -text
257
  comfyui_layerstyle/workflow/1344x768_girl2.png filter=lfs diff=lfs merge=lfs -text
258
+ comfyui_layerstyle/workflow/1344x768_redcar.png filter=lfs diff=lfs merge=lfs -text
259
+ comfyui_layerstyle/workflow/1920x1080table.png filter=lfs diff=lfs merge=lfs -text
260
+ comfyui_layerstyle/image/segment_anything_ultra_node.jpg filter=lfs diff=lfs merge=lfs -text
261
+ comfyui_layerstyle/workflow/512x512.png filter=lfs diff=lfs merge=lfs -text
262
+ comfyui_layerstyle/workflow/768x1344_dress.png filter=lfs diff=lfs merge=lfs -text
263
+ comfyui_layerstyle/workflow/768x1344_beach.png filter=lfs diff=lfs merge=lfs -text
264
+ comfyui_layerstyle/workflow/fox_512x512.png filter=lfs diff=lfs merge=lfs -text
265
+ comfyui_layerstyle/workflow/girl_dino_1024.png filter=lfs diff=lfs merge=lfs -text
266
+ comfyui_layerstyle/py/__pycache__/imagefunc.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
267
+ comfyui_layerstyle/py/__pycache__/color_name.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
268
+ ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_single_image_workflow.png filter=lfs diff=lfs merge=lfs -text
269
+ ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_video_workflow.png filter=lfs diff=lfs merge=lfs -text
270
+ ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_multiple_images_workflow.png filter=lfs diff=lfs merge=lfs -text
271
+ ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_text_workflow.png filter=lfs diff=lfs merge=lfs -text
272
+ ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00509_.png filter=lfs diff=lfs merge=lfs -text
273
+ ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00532_.png filter=lfs diff=lfs merge=lfs -text
274
+ ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00508_.png filter=lfs diff=lfs merge=lfs -text
275
+ comfyui_tensorrt/readme_images/image1.png filter=lfs diff=lfs merge=lfs -text
276
+ comfyui_tensorrt/readme_images/image10.png filter=lfs diff=lfs merge=lfs -text
277
+ comfyui_tensorrt/readme_images/image6.png filter=lfs diff=lfs merge=lfs -text
278
+ comfyui_tensorrt/readme_images/image4.png filter=lfs diff=lfs merge=lfs -text
279
+ comfyui_tensorrt/readme_images/image11.png filter=lfs diff=lfs merge=lfs -text
ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_multiple_images_workflow.png ADDED

Git LFS Details

  • SHA256: a6756f8cdf28d478a8ada56380a88b3ae9fd2cdff63c1ee730d92fa9164f935e
  • Pointer size: 131 Bytes
  • Size of remote file: 474 kB
ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_single_image_workflow.png ADDED

Git LFS Details

  • SHA256: 7969e298073fd01da833666baa199f5c4d7098c5d535e55d5282fcd5b958437d
  • Pointer size: 131 Bytes
  • Size of remote file: 455 kB
ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_text_workflow.png ADDED

Git LFS Details

  • SHA256: 06277c70708cfd2d68aadce5fd00548f9e13f6c467997feb4a13cf1864d8d70d
  • Pointer size: 131 Bytes
  • Size of remote file: 225 kB
ComfyUI_Qwen2-VL-Instruct/examples/Chat_with_video_workflow.png ADDED

Git LFS Details

  • SHA256: ef673f6e5aaecca98d8c30568bae91d2faed48422bb31197b45bcb29b2f7892b
  • Pointer size: 131 Bytes
  • Size of remote file: 422 kB
ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00508_.png ADDED

Git LFS Details

  • SHA256: bc9a61669ea0fe5b5cd51cb2178b847a65edddb77417ee9a503667200ac817c4
  • Pointer size: 131 Bytes
  • Size of remote file: 799 kB
ComfyUI_Qwen2-VL-Instruct/examples/ComfyUI_00509_.png ADDED

Git LFS Details

  • SHA256: 0d9c747f0983f07d6a4a298b28f209cb36355144d4d31339ed6222ac4d4554b2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.19 MB
comfyui_layerstyle/image/segment_anything_ultra_node.jpg ADDED

Git LFS Details

  • SHA256: 2f8f9c1d98b1cb22b07db63894c76417d7c738cb1e139771ace007536f6e739b
  • Pointer size: 131 Bytes
  • Size of remote file: 126 kB
comfyui_layerstyle/py/__pycache__/color_name.cpython-312.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16469a66469399acb0769c5d47b332f5cd184833e183d28ab4766046c8ec1300
3
+ size 102641
comfyui_layerstyle/py/__pycache__/imagefunc.cpython-312.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:03e3bcec87456dfadefc28a7d89d2d66a374b44f340e25222d3b2e68aedee619
3
+ size 151881
comfyui_layerstyle/workflow/1344x768_redcar.png ADDED

Git LFS Details

  • SHA256: 9cc37bd5b1a037a8e6c7f2f7cb95fd06720789c1150eed4c10635ce3c869a735
  • Pointer size: 131 Bytes
  • Size of remote file: 975 kB
comfyui_layerstyle/workflow/512x512.png ADDED

Git LFS Details

  • SHA256: e365fab7ab0d1093082e989ce83140dc2bb5e3df6e3ae06d57805b9d9b536574
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
comfyui_layerstyle/workflow/768x1344_beach.png ADDED

Git LFS Details

  • SHA256: 24e35250b912ef741d8c9fca5d3332fa2073ea090e7e2a865a58152a21cf997c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.12 MB
comfyui_layerstyle/workflow/768x1344_dress.png ADDED

Git LFS Details

  • SHA256: ccb884ac59e9154d348a6026438d7598256cdf923331e6b4f4b08749e8a63aa8
  • Pointer size: 132 Bytes
  • Size of remote file: 1.13 MB
comfyui_layerstyle/workflow/fox_512x512.png ADDED

Git LFS Details

  • SHA256: 1eade898ff9a30dd1d1264f8901e5b3f66bd53135c31fb4e0d8ebae1a5a31179
  • Pointer size: 131 Bytes
  • Size of remote file: 362 kB
comfyui_layerstyle/workflow/girl_dino_1024.png ADDED

Git LFS Details

  • SHA256: 6c6a56b8a246fa3efe97e83c88c5ac43e5106d71ef6ae779320158dc1cf3cfa3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.04 MB
comfyui_tensorrt/readme_images/image1.png ADDED

Git LFS Details

  • SHA256: 8b43f669a6e1b2a10c97229a8f45193b1aea3ae2f3765974e81ec00cc07f5e48
  • Pointer size: 131 Bytes
  • Size of remote file: 120 kB
comfyui_tensorrt/readme_images/image11.png ADDED

Git LFS Details

  • SHA256: 82461d397ebbbe51a70fc2bc420d5094f2df4e22ed904458f286f1735a5ad441
  • Pointer size: 131 Bytes
  • Size of remote file: 346 kB
comfyui_tensorrt/readme_images/image4.png ADDED

Git LFS Details

  • SHA256: a77f21135952119d8fb69c256a26d905b34f5820ea1ae5bb3ba9a63b82d17c93
  • Pointer size: 131 Bytes
  • Size of remote file: 524 kB
comfyui_tensorrt/readme_images/image6.png ADDED

Git LFS Details

  • SHA256: 7ccab1bd76d269de21dadd994b8cade6bb3c10ae49b963933e35893b5ed471a6
  • Pointer size: 131 Bytes
  • Size of remote file: 176 kB
rgthree-comfy/py/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (165 Bytes). View file
 
rgthree-comfy/py/__pycache__/utils_userdata.cpython-312.pyc ADDED
Binary file (2.78 kB). View file
 
rgthree-comfy/py/server/__pycache__/rgthree_server.cpython-312.pyc ADDED
Binary file (956 Bytes). View file
 
rgthree-comfy/py/server/__pycache__/routes_config.cpython-312.pyc ADDED
Binary file (2.09 kB). View file
 
rgthree-comfy/py/server/__pycache__/routes_model_info.cpython-312.pyc ADDED
Binary file (7.36 kB). View file
 
rgthree-comfy/py/server/__pycache__/utils_info.cpython-312.pyc ADDED
Binary file (16.4 kB). View file
 
rgthree-comfy/py/server/__pycache__/utils_server.cpython-312.pyc ADDED
Binary file (4.06 kB). View file
 
rgthree-comfy/src_web/comfyui/any_switch.ts ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { INodeInputSlot, INodeOutputSlot, LLink } from "typings/litegraph.js";
2
+ import type { ComfyApp, ComfyNodeConstructor, ComfyObjectInfo } from "typings/comfy.js";
3
+
4
+ import { app } from "scripts/app.js";
5
+ import { IoDirection, addConnectionLayoutSupport, followConnectionUntilType } from "./utils.js";
6
+ import { RgthreeBaseServerNode } from "./base_node.js";
7
+ import { NodeTypesString } from "./constants.js";
8
+ import { removeUnusedInputsFromEnd } from "./utils_inputs_outputs.js";
9
+ import { debounce } from "rgthree/common/shared_utils.js";
10
+
11
+ class RgthreeAnySwitch extends RgthreeBaseServerNode {
12
+ static override title = NodeTypesString.ANY_SWITCH;
13
+ static override type = NodeTypesString.ANY_SWITCH;
14
+ static comfyClass = NodeTypesString.ANY_SWITCH;
15
+
16
+ private stabilizeBound = this.stabilize.bind(this);
17
+ private nodeType: string | string[] | null = null;
18
+
19
+ constructor(title = RgthreeAnySwitch.title) {
20
+ super(title);
21
+ // Adding five. Note, configure will add as many as was in the stored workflow automatically.
22
+ this.addAnyInput(5);
23
+ }
24
+
25
+ override onConnectionsChange(
26
+ type: number,
27
+ slotIndex: number,
28
+ isConnected: boolean,
29
+ linkInfo: LLink,
30
+ ioSlot: INodeOutputSlot | INodeInputSlot,
31
+ ) {
32
+ super.onConnectionsChange?.(type, slotIndex, isConnected, linkInfo, ioSlot);
33
+ this.scheduleStabilize();
34
+ }
35
+
36
+ onConnectionsChainChange() {
37
+ this.scheduleStabilize();
38
+ }
39
+
40
+ scheduleStabilize(ms = 64) {
41
+ return debounce(this.stabilizeBound, ms);
42
+ }
43
+
44
+ private addAnyInput(num = 1) {
45
+ for (let i = 0; i < num; i++) {
46
+ this.addInput(
47
+ `any_${String(this.inputs.length + 1).padStart(2, "0")}`,
48
+ (this.nodeType || "*") as string,
49
+ );
50
+ }
51
+ }
52
+
53
+ stabilize() {
54
+ // First, clean up the dynamic number of inputs.
55
+ removeUnusedInputsFromEnd(this, 4);
56
+ this.addAnyInput();
57
+
58
+ // We prefer the inputs, then the output.
59
+ let connectedType = followConnectionUntilType(this, IoDirection.INPUT, undefined, true);
60
+ if (!connectedType) {
61
+ connectedType = followConnectionUntilType(this, IoDirection.OUTPUT, undefined, true);
62
+ }
63
+ // TODO: What this doesn't do is broadcast to other nodes when its type changes. Reroute node
64
+ // does, but, for now, if this was connected to another Any Switch, say, the second one wouldn't
65
+ // change its type when the first does. The user would need to change the connections.
66
+ this.nodeType = connectedType?.type || "*";
67
+ for (const input of this.inputs) {
68
+ input.type = this.nodeType as string; // So, types can indeed be arrays,,
69
+ }
70
+ for (const output of this.outputs) {
71
+ output.type = this.nodeType as string; // So, types can indeed be arrays,,
72
+ output.label =
73
+ output.type === "RGTHREE_CONTEXT"
74
+ ? "CONTEXT"
75
+ : Array.isArray(this.nodeType) || this.nodeType.includes(",")
76
+ ? connectedType?.label || String(this.nodeType)
77
+ : String(this.nodeType);
78
+ }
79
+ }
80
+
81
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
82
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeAnySwitch);
83
+ addConnectionLayoutSupport(RgthreeAnySwitch, app, [
84
+ ["Left", "Right"],
85
+ ["Right", "Left"],
86
+ ]);
87
+ }
88
+ }
89
+
90
+ app.registerExtension({
91
+ name: "rgthree.AnySwitch",
92
+ async beforeRegisterNodeDef(
93
+ nodeType: ComfyNodeConstructor,
94
+ nodeData: ComfyObjectInfo,
95
+ app: ComfyApp,
96
+ ) {
97
+ if (nodeData.name === "Any Switch (rgthree)") {
98
+ RgthreeAnySwitch.setUp(nodeType, nodeData);
99
+ }
100
+ },
101
+ });
rgthree-comfy/src_web/comfyui/base_any_input_connected_node.ts ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RgthreeBaseVirtualNodeConstructor } from "typings/rgthree.js";
2
+ import type {
3
+ Vector2,
4
+ LLink,
5
+ INodeInputSlot,
6
+ INodeOutputSlot,
7
+ LGraphNode as TLGraphNode,
8
+ IWidget,
9
+ } from "typings/litegraph.js";
10
+
11
+ import { app } from "scripts/app.js";
12
+ import { RgthreeBaseVirtualNode } from "./base_node.js";
13
+ import { rgthree } from "./rgthree.js";
14
+
15
+ import {
16
+ PassThroughFollowing,
17
+ addConnectionLayoutSupport,
18
+ addMenuItem,
19
+ getConnectedInputNodes,
20
+ getConnectedInputNodesAndFilterPassThroughs,
21
+ getConnectedOutputNodes,
22
+ getConnectedOutputNodesAndFilterPassThroughs,
23
+ } from "./utils.js";
24
+
25
+ /**
26
+ * A Virtual Node that allows any node's output to connect to it.
27
+ */
28
+ export class BaseAnyInputConnectedNode extends RgthreeBaseVirtualNode {
29
+ override isVirtualNode = true;
30
+
31
+ /**
32
+ * Whether inputs show the immediate nodes, or follow and show connected nodes through
33
+ * passthrough nodes.
34
+ */
35
+ readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.NONE;
36
+
37
+ debouncerTempWidth: number = 0;
38
+ schedulePromise: Promise<void> | null = null;
39
+
40
+ constructor(title = BaseAnyInputConnectedNode.title) {
41
+ super(title);
42
+ }
43
+
44
+ override onConstructed() {
45
+ this.addInput("", "*");
46
+ return super.onConstructed();
47
+ }
48
+
49
+ override clone() {
50
+ const cloned = super.clone();
51
+ // Copying to clipboard (and also, creating node templates) work by cloning nodes and, for some
52
+ // reason, it manually manipulates the cloned data. So, we want to keep the present input slots
53
+ // so if it's pasted/templatized the data is correct. Otherwise, clear the inputs and so the new
54
+ // node is ready to go, fresh.
55
+ if (!rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes) {
56
+ while (cloned.inputs.length > 1) {
57
+ cloned.removeInput(cloned.inputs.length - 1);
58
+ }
59
+ if (cloned.inputs[0]) {
60
+ cloned.inputs[0].label = "";
61
+ }
62
+ }
63
+ return cloned;
64
+ }
65
+
66
+ /**
67
+ * Schedules a promise to run a stabilization, debouncing duplicate requests.
68
+ */
69
+ scheduleStabilizeWidgets(ms = 100) {
70
+ if (!this.schedulePromise) {
71
+ this.schedulePromise = new Promise((resolve) => {
72
+ setTimeout(() => {
73
+ this.schedulePromise = null;
74
+ this.doStablization();
75
+ resolve();
76
+ }, ms);
77
+ });
78
+ }
79
+ return this.schedulePromise;
80
+ }
81
+
82
+ /**
83
+ * Ensures we have at least one empty input at the end, returns true if changes were made, or false
84
+ * if no changes were needed.
85
+ */
86
+ private stabilizeInputsOutputs() : boolean {
87
+ let changed = false;
88
+ const hasEmptyInput = !this.inputs[this.inputs.length - 1]?.link;
89
+ if (!hasEmptyInput) {
90
+ this.addInput("", "*");
91
+ changed = true;
92
+ }
93
+ for (let index = this.inputs.length - 2; index >= 0; index--) {
94
+ const input = this.inputs[index]!;
95
+ if (!input.link) {
96
+ this.removeInput(index);
97
+ changed = true;
98
+ } else {
99
+ const node = getConnectedInputNodesAndFilterPassThroughs(
100
+ this,
101
+ this,
102
+ index,
103
+ this.inputsPassThroughFollowing,
104
+ )[0];
105
+ const newName = node?.title || "";
106
+ if (input.name !== newName) {
107
+ input.name = node?.title || "";
108
+ changed = true;
109
+ }
110
+ }
111
+ }
112
+ return changed;
113
+ }
114
+
115
+ /**
116
+ * Stabilizes the node's inputs and widgets.
117
+ */
118
+ private doStablization() {
119
+ if (!this.graph) {
120
+ return;
121
+ }
122
+ let dirty = false;
123
+
124
+ // When we add/remove widgets, litegraph is going to mess up the size, so we
125
+ // store it so we can retrieve it in computeSize. Hacky..
126
+ (this as any)._tempWidth = this.size[0];
127
+
128
+ dirty = this.stabilizeInputsOutputs();
129
+ const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
130
+ dirty = this.handleLinkedNodesStabilization(linkedNodes) || dirty;
131
+
132
+ // Only mark dirty if something's changed.
133
+ if (dirty) {
134
+ app.graph.setDirtyCanvas(true, true);
135
+ }
136
+
137
+ // Schedule another stabilization in the future.
138
+ this.scheduleStabilizeWidgets(500);
139
+ }
140
+
141
+ /**
142
+ * Handles stabilization of linked nodes. To be overridden. Should return true if changes were
143
+ * made, or false if no changes were needed.
144
+ */
145
+ handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]) : boolean {
146
+ linkedNodes; // No-op, but makes overridding in VSCode cleaner.
147
+ throw new Error("handleLinkedNodesStabilization should be overridden.");
148
+ }
149
+
150
+ onConnectionsChainChange() {
151
+ this.scheduleStabilizeWidgets();
152
+ }
153
+
154
+ override onConnectionsChange(
155
+ type: number,
156
+ index: number,
157
+ connected: boolean,
158
+ linkInfo: LLink,
159
+ ioSlot: INodeOutputSlot | INodeInputSlot,
160
+ ) {
161
+ super.onConnectionsChange &&
162
+ super.onConnectionsChange(type, index, connected, linkInfo, ioSlot);
163
+ if (!linkInfo) return;
164
+ // Follow outputs to see if we need to trigger an onConnectionChange.
165
+ const connectedNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
166
+ for (const node of connectedNodes) {
167
+ if ((node as BaseAnyInputConnectedNode).onConnectionsChainChange) {
168
+ (node as BaseAnyInputConnectedNode).onConnectionsChainChange();
169
+ }
170
+ }
171
+ this.scheduleStabilizeWidgets();
172
+ }
173
+
174
+ override removeInput(slot: number) {
175
+ (this as any)._tempWidth = this.size[0];
176
+ return super.removeInput(slot);
177
+ }
178
+
179
+ override addInput(name: string, type: string | -1, extra_info?: Partial<INodeInputSlot>) {
180
+ (this as any)._tempWidth = this.size[0];
181
+ return super.addInput(name, type, extra_info);
182
+ }
183
+
184
+ override addWidget<T extends IWidget>(
185
+ type: T["type"],
186
+ name: string,
187
+ value: T["value"],
188
+ callback?: T["callback"] | string,
189
+ options?: T["options"],
190
+ ) {
191
+ (this as any)._tempWidth = this.size[0];
192
+ return super.addWidget(type, name, value, callback, options);
193
+ }
194
+
195
+ /**
196
+ * Guess this doesn't exist in Litegraph...
197
+ */
198
+ override removeWidget(widgetOrSlot?: IWidget | number) {
199
+ (this as any)._tempWidth = this.size[0];
200
+ super.removeWidget(widgetOrSlot);
201
+ }
202
+
203
+ override computeSize(out: Vector2) {
204
+ let size = super.computeSize(out);
205
+ if ((this as any)._tempWidth) {
206
+ size[0] = (this as any)._tempWidth;
207
+ // We sometimes get repeated calls to compute size, so debounce before clearing.
208
+ this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
209
+ this.debouncerTempWidth = setTimeout(() => {
210
+ (this as any)._tempWidth = null;
211
+ }, 32);
212
+ }
213
+ // If we're collapsed, then subtract the total calculated height of the other input slots.
214
+ if (this.properties["collapse_connections"]) {
215
+ const rows = Math.max(this.inputs?.length || 0, this.outputs?.length || 0, 1) - 1;
216
+ size[1] = size[1] - rows * LiteGraph.NODE_SLOT_HEIGHT;
217
+ }
218
+ setTimeout(() => {
219
+ app.graph.setDirtyCanvas(true, true);
220
+ }, 16);
221
+ return size;
222
+ }
223
+
224
+ /**
225
+ * When we connect our output, check our inputs and make sure we're not trying to connect a loop.
226
+ */
227
+ override onConnectOutput(
228
+ outputIndex: number,
229
+ inputType: string | -1,
230
+ inputSlot: INodeInputSlot,
231
+ inputNode: TLGraphNode,
232
+ inputIndex: number,
233
+ ): boolean {
234
+ let canConnect = true;
235
+ if (super.onConnectOutput) {
236
+ canConnect = super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
237
+ }
238
+ if (canConnect) {
239
+ const nodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
240
+ if (nodes.includes(inputNode)) {
241
+ alert(
242
+ `Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
243
+ `a situation that could create a time paradox, the results of which could cause a ` +
244
+ `chain reaction that would unravel the very fabric of the space time continuum, ` +
245
+ `and destroy the entire universe!`,
246
+ );
247
+ canConnect = false;
248
+ }
249
+ }
250
+ return canConnect;
251
+ }
252
+
253
+ override onConnectInput(
254
+ inputIndex: number,
255
+ outputType: string | -1,
256
+ outputSlot: INodeOutputSlot,
257
+ outputNode: TLGraphNode,
258
+ outputIndex: number,
259
+ ): boolean {
260
+ let canConnect = true;
261
+ if (super.onConnectInput) {
262
+ canConnect = super.onConnectInput(
263
+ inputIndex,
264
+ outputType,
265
+ outputSlot,
266
+ outputNode,
267
+ outputIndex,
268
+ );
269
+ }
270
+ if (canConnect) {
271
+ const nodes = getConnectedOutputNodes(this); // We want passthrough nodes, since they will loop.
272
+ if (nodes.includes(outputNode)) {
273
+ alert(
274
+ `Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
275
+ `a situation that could create a time paradox, the results of which could cause a ` +
276
+ `chain reaction that would unravel the very fabric of the space time continuum, ` +
277
+ `and destroy the entire universe!`,
278
+ );
279
+ canConnect = false;
280
+ }
281
+ }
282
+ return canConnect;
283
+ }
284
+
285
+ /**
286
+ * If something is dropped on us, just add it to the bottom. onConnectInput should already cancel
287
+ * if it's disallowed.
288
+ */
289
+ override connectByTypeOutput<T = any>(
290
+ slot: string | number,
291
+ sourceNode: TLGraphNode,
292
+ sourceSlotType: string,
293
+ optsIn: string,
294
+ ): T | null {
295
+ const lastInput = this.inputs[this.inputs.length - 1];
296
+ if (!lastInput?.link && lastInput?.type === "*") {
297
+ var sourceSlot = sourceNode.findOutputSlotByType(sourceSlotType, false, true);
298
+ return sourceNode.connect(sourceSlot, this, slot);
299
+ }
300
+ return super.connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn);
301
+ }
302
+
303
+ static override setUp() {
304
+ super.setUp();
305
+ addConnectionLayoutSupport(this, app, [
306
+ ["Left", "Right"],
307
+ ["Right", "Left"],
308
+ ]);
309
+ addMenuItem(this, app, {
310
+ name: (node) =>
311
+ `${node.properties?.["collapse_connections"] ? "Show" : "Collapse"} Connections`,
312
+ property: "collapse_connections",
313
+ prepareValue: (_value, node) => !node.properties?.["collapse_connections"],
314
+ callback: (_node) => {
315
+ app.graph.setDirtyCanvas(true, true);
316
+ },
317
+ });
318
+ }
319
+ }
320
+
321
+ // Ok, hack time! LGraphNode's connectByType is powerful, but for our nodes, that have multiple "*"
322
+ // input types, it seems it just takes the first one, and disconnects it. I'd rather we don't do
323
+ // that and instead take the next free one. If that doesn't work, then we'll give it to the old
324
+ // method.
325
+ const oldLGraphNodeConnectByType = LGraphNode.prototype.connectByType;
326
+ LGraphNode.prototype.connectByType = function connectByType<T = any>(
327
+ slot: string | number,
328
+ sourceNode: TLGraphNode,
329
+ sourceSlotType: string,
330
+ optsIn: string,
331
+ ): T | null {
332
+ // If we're droppiong on a node, and the last input is free and an "*" type, then connect there
333
+ // first...
334
+ if (sourceNode.inputs) {
335
+ for (const [index, input] of sourceNode.inputs.entries()) {
336
+ if (!input.link && input.type === "*") {
337
+ this.connect(slot, sourceNode, index);
338
+ return null;
339
+ }
340
+ }
341
+ }
342
+ return ((oldLGraphNodeConnectByType &&
343
+ oldLGraphNodeConnectByType.call(this, slot, sourceNode, sourceSlotType, optsIn)) ||
344
+ null) as T;
345
+ };
rgthree-comfy/src_web/comfyui/base_node.ts ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ComfyNodeConstructor, ComfyObjectInfo, NodeMode } from "typings/comfy.js";
2
+ import type {
3
+ IWidget,
4
+ SerializedLGraphNode,
5
+ LGraphNode as TLGraphNode,
6
+ LGraphCanvas,
7
+ ContextMenuItem,
8
+ INodeOutputSlot,
9
+ INodeInputSlot,
10
+ } from "typings/litegraph.js";
11
+ import type { RgthreeBaseServerNodeConstructor, RgthreeBaseVirtualNodeConstructor } from "typings/rgthree.js";
12
+
13
+ import { ComfyWidgets } from "scripts/widgets.js";
14
+ import { SERVICE as KEY_EVENT_SERVICE } from "./services/key_events_services.js";
15
+ import { app } from "scripts/app.js";
16
+ import { LogLevel, rgthree } from "./rgthree.js";
17
+ import { addHelpMenuItem } from "./utils.js";
18
+ import { RgthreeHelpDialog } from "rgthree/common/dialog.js";
19
+ import {
20
+ importIndividualNodesInnerOnDragDrop,
21
+ importIndividualNodesInnerOnDragOver,
22
+ } from "./feature_import_individual_nodes.js";
23
+ import { defineProperty } from "rgthree/common/shared_utils.js";
24
+
25
+ /**
26
+ * A base node with standard methods, directly extending the LGraphNode.
27
+ * This can be used for ui-nodes and a further base for server nodes.
28
+ */
29
+ export abstract class RgthreeBaseNode extends LGraphNode {
30
+ /**
31
+ * Action strings that can be exposed and triggered from other nodes, like Fast Actions Button.
32
+ */
33
+ static exposedActions: string[] = [];
34
+
35
+ static override title: string = "__NEED_CLASS_TITLE__";
36
+ static category = "rgthree";
37
+ static _category = "rgthree"; // `category` seems to get reset by comfy, so reset to this after.
38
+
39
+ /**
40
+ * The comfyClass is property ComfyUI and extensions may care about, even through it is only for
41
+ * server nodes. RgthreeBaseServerNode below overrides this with the expected value and we just
42
+ * set it here so extensions that are none the wiser don't break on some unchecked string method
43
+ * call on an undefined calue.
44
+ */
45
+ comfyClass: string = "__NEED_COMFY_CLASS__";
46
+
47
+ /** Used by the ComfyUI-Manager badge. */
48
+ readonly nickname = "rgthree";
49
+ /** Are we a virtual node? */
50
+ readonly isVirtualNode: boolean = false;
51
+ /** Are we able to be dropped on (if config is enabled too). */
52
+ isDropEnabled = false;
53
+ /** A state member determining if we're currently removed. */
54
+ removed = false;
55
+ /** A state member determining if we're currently "configuring."" */
56
+ configuring = false;
57
+ /** A temporary width value that can be used to ensure compute size operates correctly. */
58
+ _tempWidth = 0;
59
+
60
+ /** Private Mode member so we can override the setter/getter and call an `onModeChange`. */
61
+ private rgthree_mode: NodeMode;
62
+ /** An internal bool set when `onConstructed` is run. */
63
+ private __constructed__ = false;
64
+ /** The help dialog. */
65
+ private helpDialog: RgthreeHelpDialog | null = null;
66
+
67
+ constructor(title = RgthreeBaseNode.title, skipOnConstructedCall = true) {
68
+ super(title);
69
+ if (title == "__NEED_CLASS_TITLE__") {
70
+ throw new Error("RgthreeBaseNode needs overrides.");
71
+ }
72
+ // Ensure these exist since some other extensions will break in their onNodeCreated.
73
+ this.widgets = this.widgets || [];
74
+ this.properties = this.properties || {};
75
+
76
+ // Some checks we want to do after we're constructed, looking that data is set correctly and
77
+ // that our base's `onConstructed` was called (if not, set a DEV warning).
78
+ setTimeout(() => {
79
+ // Check we have a comfyClass defined.
80
+ if (this.comfyClass == "__NEED_COMFY_CLASS__") {
81
+ throw new Error("RgthreeBaseNode needs a comfy class override.");
82
+ }
83
+ // Ensure we've called onConstructed before we got here.
84
+ this.checkAndRunOnConstructed();
85
+ });
86
+
87
+ defineProperty(this, 'mode', {
88
+ get: () => {
89
+ return this.rgthree_mode;
90
+ },
91
+ set: (mode: NodeMode) => {
92
+ if (this.rgthree_mode != mode) {
93
+ const oldMode = this.rgthree_mode;
94
+ this.rgthree_mode = mode;
95
+ this.onModeChange(oldMode, mode);
96
+ }
97
+ },
98
+ });
99
+ }
100
+
101
+ private checkAndRunOnConstructed() {
102
+ if (!this.__constructed__) {
103
+ this.onConstructed();
104
+ const [n, v] = rgthree.logger.logParts(
105
+ LogLevel.DEV,
106
+ `[RgthreeBaseNode] Child class did not call onConstructed for "${this.type}.`,
107
+ );
108
+ console[n]?.(...v);
109
+ }
110
+ return this.__constructed__;
111
+ }
112
+
113
+ onDragOver(e: DragEvent): boolean {
114
+ if (!this.isDropEnabled) return false;
115
+ return importIndividualNodesInnerOnDragOver(this, e);
116
+ }
117
+
118
+ async onDragDrop(e: DragEvent): Promise<boolean> {
119
+ if (!this.isDropEnabled) return false;
120
+ return importIndividualNodesInnerOnDragDrop(this, e);
121
+ }
122
+
123
+ /**
124
+ * When a node is finished with construction, we must call this. Failure to do so will result in
125
+ * an error message from the timeout in this base class. This is broken out and becomes the
126
+ * responsibility of the child class because
127
+ */
128
+ onConstructed() {
129
+ if (this.__constructed__) return false;
130
+ // This is kinda a hack, but if this.type is still null, then set it to undefined to match.
131
+ this.type = this.type ?? undefined;
132
+ this.__constructed__ = true;
133
+ rgthree.invokeExtensionsAsync("nodeCreated", this);
134
+ return this.__constructed__;
135
+ }
136
+
137
+ override configure(info: SerializedLGraphNode<TLGraphNode>): void {
138
+ this.configuring = true;
139
+ super.configure(info);
140
+ // Fix https://github.com/comfyanonymous/ComfyUI/issues/1448 locally.
141
+ // Can removed when fixed and adopted.
142
+ for (const w of this.widgets || []) {
143
+ w.last_y = w.last_y || 0;
144
+ }
145
+ this.configuring = false;
146
+ }
147
+
148
+ /**
149
+ * Override clone for, at the least, deep-copying properties.
150
+ */
151
+ override clone() {
152
+ const cloned = super.clone();
153
+ // This is whild, but LiteGraph clone doesn't deep clone data, so we will. We'll use structured
154
+ // clone, which most browsers in 2022 support, but but we'll check.
155
+ if (cloned.properties && !!window.structuredClone) {
156
+ cloned.properties = structuredClone(cloned.properties);
157
+ }
158
+ return cloned;
159
+ }
160
+
161
+ /** When a mode change, we want all connected nodes to match. */
162
+ onModeChange(from: NodeMode, to: NodeMode) {
163
+ // Override
164
+ }
165
+
166
+ /**
167
+ * Given a string, do something. At the least, handle any `exposedActions` that may be called and
168
+ * passed into from other nodes, like Fast Actions Button
169
+ */
170
+ async handleAction(action: string) {
171
+ action; // No-op. Should be overridden but OK if not.
172
+ }
173
+
174
+ /**
175
+ * Guess this doesn't exist in Litegraph...
176
+ */
177
+ removeWidget(widgetOrSlot?: IWidget | number) {
178
+ if (typeof widgetOrSlot === "number") {
179
+ this.widgets.splice(widgetOrSlot, 1);
180
+ } else if (widgetOrSlot) {
181
+ const index = this.widgets.indexOf(widgetOrSlot);
182
+ if (index > -1) {
183
+ this.widgets.splice(index, 1);
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * A default version of the logive when a node does not set `getSlotMenuOptions`. This is
190
+ * necessary because child nodes may want to define getSlotMenuOptions but LiteGraph then won't do
191
+ * it's default logic. This bakes it so child nodes can call this instead (and this doesn't set
192
+ * getSlotMenuOptions for all child nodes in case it doesn't exist).
193
+ */
194
+ defaultGetSlotMenuOptions(slot: {
195
+ input?: INodeInputSlot;
196
+ output?: INodeOutputSlot;
197
+ }): ContextMenuItem[] | null {
198
+ const menu_info: ContextMenuItem[] = [];
199
+ if (slot?.output?.links?.length) {
200
+ menu_info.push({ content: "Disconnect Links", slot: slot });
201
+ }
202
+ let inputOrOutput = slot.input || slot.output;
203
+ if (inputOrOutput) {
204
+ if (inputOrOutput.removable) {
205
+ menu_info.push(
206
+ inputOrOutput.locked ? { content: "Cannot remove" } : { content: "Remove Slot", slot },
207
+ );
208
+ }
209
+ if (!inputOrOutput.nameLocked) {
210
+ menu_info.push({ content: "Rename Slot", slot });
211
+ }
212
+ }
213
+ return menu_info;
214
+ }
215
+
216
+ override onRemoved(): void {
217
+ super.onRemoved?.();
218
+ this.removed = true;
219
+ }
220
+
221
+ static setUp<T extends RgthreeBaseNode>(...args: any[]) {
222
+ // No-op.
223
+ }
224
+
225
+ /**
226
+ * A function to provide help text to be overridden.
227
+ */
228
+ getHelp() {
229
+ return "";
230
+ }
231
+
232
+ showHelp() {
233
+ const help = this.getHelp() || (this.constructor as any).help;
234
+ if (help) {
235
+ this.helpDialog = new RgthreeHelpDialog(this, help).show();
236
+ this.helpDialog.addEventListener("close", (e) => {
237
+ this.helpDialog = null;
238
+ });
239
+ }
240
+ }
241
+
242
+ override onKeyDown(event: KeyboardEvent): void {
243
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
244
+ if (event.key == "?" && !this.helpDialog) {
245
+ this.showHelp();
246
+ }
247
+ }
248
+
249
+ override onKeyUp(event: KeyboardEvent): void {
250
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
251
+ }
252
+
253
+ override getExtraMenuOptions(canvas: LGraphCanvas, options: ContextMenuItem[]): void {
254
+ // Some other extensions override getExtraMenuOptions on the nodeType as it comes through from
255
+ // the server, so we can call out to that if we don't have our own.
256
+ if (super.getExtraMenuOptions) {
257
+ super.getExtraMenuOptions?.apply(this, [canvas, options]);
258
+ } else if ((this.constructor as any).nodeType?.prototype?.getExtraMenuOptions) {
259
+ (this.constructor as any).nodeType?.prototype?.getExtraMenuOptions?.apply(this, [
260
+ canvas,
261
+ options,
262
+ ]);
263
+ }
264
+ // If we have help content, then add a menu item.
265
+ const help = this.getHelp() || (this.constructor as any).help;
266
+ if (help) {
267
+ addHelpMenuItem(this, help, options);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * A virtual node. Right now, this is just a wrapper for RgthreeBaseNode (which was the initial
274
+ * base virtual node).
275
+ *
276
+ * TODO: Make RgthreeBaseNode private and move all virtual nodes to this class; cleanup
277
+ * RgthreeBaseNode assumptions that its virtual.
278
+ */
279
+ export class RgthreeBaseVirtualNode extends RgthreeBaseNode {
280
+ override isVirtualNode = true;
281
+
282
+ constructor(title = RgthreeBaseNode.title) {
283
+ super(title, false);
284
+ }
285
+
286
+ static override setUp() {
287
+ if (!this.type) {
288
+ throw new Error(`Missing type for RgthreeBaseVirtualNode: ${this.title}`);
289
+ }
290
+ LiteGraph.registerNodeType(this.type, this);
291
+ if (this._category) {
292
+ this.category = this._category;
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * A base node with standard methods, extending the LGraphNode.
299
+ * This is somewhat experimental, but if comfyui is going to keep breaking widgets and inputs, it
300
+ * seems safer than NOT overriding.
301
+ */
302
+ export class RgthreeBaseServerNode extends RgthreeBaseNode {
303
+ static nodeData: ComfyObjectInfo | null = null;
304
+ static nodeType: ComfyNodeConstructor | null = null;
305
+
306
+ // Drop is enabled by default for server nodes.
307
+ override isDropEnabled = true;
308
+
309
+ constructor(title: string) {
310
+ super(title, true);
311
+ this.serialize_widgets = true;
312
+ this.setupFromServerNodeData();
313
+ this.onConstructed();
314
+ }
315
+
316
+ getWidgets() {
317
+ return ComfyWidgets;
318
+ }
319
+
320
+ /**
321
+ * This takes the server data and builds out the inputs, outputs and widgets. It's similar to the
322
+ * ComfyNode constructor in registerNodes in ComfyUI's app.js, but is more stable and thus
323
+ * shouldn't break as often when it modifyies widgets and types.
324
+ */
325
+ async setupFromServerNodeData() {
326
+ const nodeData = (this.constructor as any).nodeData;
327
+ if (!nodeData) {
328
+ throw Error("No node data");
329
+ }
330
+
331
+ // Necessary for serialization so Comfy backend can check types.
332
+ // Serialized as `class_type`. See app.js#graphToPrompt
333
+ this.comfyClass = nodeData.name;
334
+
335
+ let inputs = nodeData["input"]["required"];
336
+ if (nodeData["input"]["optional"] != undefined) {
337
+ inputs = Object.assign({}, inputs, nodeData["input"]["optional"]);
338
+ }
339
+
340
+ const WIDGETS = this.getWidgets();
341
+
342
+ const config: { minWidth: number; minHeight: number; widget?: null | { options: any } } = {
343
+ minWidth: 1,
344
+ minHeight: 1,
345
+ widget: null,
346
+ };
347
+ for (const inputName in inputs) {
348
+ const inputData = inputs[inputName];
349
+ const type = inputData[0];
350
+ // If we're forcing the input, just do it now and forget all that widget stuff.
351
+ // This is one of the differences from ComfyNode and provides smoother experience for inputs
352
+ // that are going to remain inputs anyway.
353
+ // Also, it fixes https://github.com/comfyanonymous/ComfyUI/issues/1404 (for rgthree nodes)
354
+ if (inputData[1]?.forceInput) {
355
+ this.addInput(inputName, type);
356
+ } else {
357
+ let widgetCreated = true;
358
+ if (Array.isArray(type)) {
359
+ // Enums
360
+ Object.assign(config, WIDGETS.COMBO(this, inputName, inputData, app) || {});
361
+ } else if (`${type}:${inputName}` in WIDGETS) {
362
+ // Support custom widgets by Type:Name
363
+ Object.assign(
364
+ config,
365
+ WIDGETS[`${type}:${inputName}`]!(this, inputName, inputData, app) || {},
366
+ );
367
+ } else if (type in WIDGETS) {
368
+ // Standard type widgets
369
+ Object.assign(config, WIDGETS[type]!(this, inputName, inputData, app) || {});
370
+ } else {
371
+ // Node connection inputs
372
+ this.addInput(inputName, type);
373
+ widgetCreated = false;
374
+ }
375
+
376
+ // Don't actually need this right now, but ported it over from ComfyWidget.
377
+ if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
378
+ if (!config.widget.options) config.widget.options = {};
379
+ config.widget.options.forceInput = inputData[1].forceInput;
380
+ }
381
+ if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
382
+ if (!config.widget.options) config.widget.options = {};
383
+ config.widget.options.defaultInput = inputData[1].defaultInput;
384
+ }
385
+ }
386
+ }
387
+
388
+ for (const o in nodeData["output"]) {
389
+ let output = nodeData["output"][o];
390
+ if (output instanceof Array) output = "COMBO";
391
+ const outputName = nodeData["output_name"][o] || output;
392
+ const outputShape = nodeData["output_is_list"][o]
393
+ ? LiteGraph.GRID_SHAPE
394
+ : LiteGraph.CIRCLE_SHAPE;
395
+ this.addOutput(outputName, output, { shape: outputShape });
396
+ }
397
+
398
+ const s = this.computeSize();
399
+ s[0] = Math.max(config.minWidth, s[0] * 1.5);
400
+ s[1] = Math.max(config.minHeight, s[1]);
401
+ this.size = s;
402
+ this.serialize_widgets = true;
403
+ }
404
+
405
+ static __registeredForOverride__: boolean = false;
406
+ static registerForOverride(
407
+ comfyClass: ComfyNodeConstructor,
408
+ nodeData: ComfyObjectInfo,
409
+ rgthreeClass: RgthreeBaseServerNodeConstructor,
410
+ ) {
411
+ if (OVERRIDDEN_SERVER_NODES.has(comfyClass)) {
412
+ throw Error(
413
+ `Already have a class to override ${
414
+ comfyClass.type || comfyClass.name || comfyClass.title
415
+ }`,
416
+ );
417
+ }
418
+ OVERRIDDEN_SERVER_NODES.set(comfyClass, rgthreeClass);
419
+ // Mark the rgthreeClass as `__registeredForOverride__` because ComfyUI will repeatedly call
420
+ // this and certain setups will only want to setup once (like adding context menus, etc).
421
+ if (!rgthreeClass.__registeredForOverride__) {
422
+ rgthreeClass.__registeredForOverride__ = true;
423
+ rgthreeClass.nodeType = comfyClass;
424
+ rgthreeClass.nodeData = nodeData;
425
+ rgthreeClass.onRegisteredForOverride(comfyClass, rgthreeClass);
426
+ }
427
+ }
428
+
429
+ static onRegisteredForOverride(comfyClass: any, rgthreeClass: any) {
430
+ // To be overridden
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Keeps track of the rgthree-comfy nodes that come from the server (and want to be ComfyNodes) that
436
+ * we override into a own, more flexible and cleaner nodes.
437
+ */
438
+ const OVERRIDDEN_SERVER_NODES = new Map<any, any>();
439
+
440
+ const oldregisterNodeType = LiteGraph.registerNodeType;
441
+ /**
442
+ * ComfyUI calls registerNodeType with its ComfyNode, but we don't trust that will remain stable, so
443
+ * we need to identify it, intercept it, and supply our own class for the node.
444
+ */
445
+ LiteGraph.registerNodeType = async function (nodeId: string, baseClass: any) {
446
+ const clazz = OVERRIDDEN_SERVER_NODES.get(baseClass) || baseClass;
447
+ if (clazz !== baseClass) {
448
+ const classLabel = clazz.type || clazz.name || clazz.title;
449
+ const [n, v] = rgthree.logger.logParts(
450
+ LogLevel.DEBUG,
451
+ `${nodeId}: replacing default ComfyNode implementation with custom ${classLabel} class.`,
452
+ );
453
+ console[n]?.(...v);
454
+ // Note, we don't currently call our rgthree.invokeExtensionsAsync w/ beforeRegisterNodeDef as
455
+ // this runs right after that. However, this does mean that extensions cannot actually change
456
+ // anything about overriden server rgthree nodes in their beforeRegisterNodeDef (as when comfy
457
+ // calls it, it's for the wrong ComfyNode class). Calling it here, however, would re-run
458
+ // everything causing more issues than not. If we wanted to support beforeRegisterNodeDef then
459
+ // it would mean rewriting ComfyUI's registerNodeDef which, frankly, is not worth it.
460
+ }
461
+ return oldregisterNodeType.call(LiteGraph, nodeId, clazz);
462
+ };
rgthree-comfy/src_web/comfyui/base_node_collector.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { INodeOutputSlot, LGraphNode } from "typings/litegraph.js";
2
+ import { rgthree } from "./rgthree.js";
3
+ import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js";
4
+ import {
5
+ PassThroughFollowing,
6
+ getConnectedInputNodes,
7
+ getConnectedInputNodesAndFilterPassThroughs,
8
+ shouldPassThrough,
9
+ } from "./utils.js";
10
+
11
+ /**
12
+ * Base collector node that monitors changing inputs and outputs.
13
+ */
14
+ export class BaseCollectorNode extends BaseAnyInputConnectedNode {
15
+ /**
16
+ * We only want to show nodes through re_route nodes, other pass through nodes show each input.
17
+ */
18
+ override readonly inputsPassThroughFollowing: PassThroughFollowing =
19
+ PassThroughFollowing.REROUTE_ONLY;
20
+
21
+ readonly logger = rgthree.newLogSession("[BaseCollectorNode]");
22
+
23
+ constructor(title?: string) {
24
+ super(title);
25
+ }
26
+
27
+ override clone() {
28
+ const cloned = super.clone();
29
+ return cloned;
30
+ }
31
+
32
+ override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
33
+ return false; // No-op, no widgets.
34
+ }
35
+
36
+ /**
37
+ * When we connect an input, check to see if it's already connected and cancel it.
38
+ */
39
+ override onConnectInput(
40
+ inputIndex: number,
41
+ outputType: string | -1,
42
+ outputSlot: INodeOutputSlot,
43
+ outputNode: LGraphNode,
44
+ outputIndex: number,
45
+ ): boolean {
46
+ let canConnect = super.onConnectInput(
47
+ inputIndex,
48
+ outputType,
49
+ outputSlot,
50
+ outputNode,
51
+ outputIndex,
52
+ );
53
+ if (canConnect) {
54
+ const allConnectedNodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
55
+ const nodesAlreadyInSlot = getConnectedInputNodes(this, undefined, inputIndex);
56
+ if (allConnectedNodes.includes(outputNode)) {
57
+ // If we're connecting to the same slot, then allow it by replacing the one we have.
58
+ // const slotsOriginNode = getOriginNodeByLink(this.inputs[inputIndex]?.link);
59
+ const [n, v] = this.logger.debugParts(
60
+ `${outputNode.title} is already connected to ${this.title}.`,
61
+ );
62
+ console[n]?.(...v);
63
+ if (nodesAlreadyInSlot.includes(outputNode)) {
64
+ const [n, v] = this.logger.debugParts(
65
+ `... but letting it slide since it's for the same slot.`,
66
+ );
67
+ console[n]?.(...v);
68
+ } else {
69
+ canConnect = false;
70
+ }
71
+ }
72
+ if (canConnect && shouldPassThrough(outputNode, PassThroughFollowing.REROUTE_ONLY)) {
73
+ const connectedNode = getConnectedInputNodesAndFilterPassThroughs(
74
+ outputNode,
75
+ undefined,
76
+ undefined,
77
+ PassThroughFollowing.REROUTE_ONLY,
78
+ )[0];
79
+ if (connectedNode && allConnectedNodes.includes(connectedNode)) {
80
+ // If we're connecting to the same slot, then allow it by replacing the one we have.
81
+ const [n, v] = this.logger.debugParts(
82
+ `${connectedNode.title} is already connected to ${this.title}.`,
83
+ );
84
+ console[n]?.(...v);
85
+ if (nodesAlreadyInSlot.includes(connectedNode)) {
86
+ const [n, v] = this.logger.debugParts(
87
+ `... but letting it slide since it's for the same slot.`,
88
+ );
89
+ console[n]?.(...v);
90
+ } else {
91
+ canConnect = false;
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return canConnect;
97
+ }
98
+ }
rgthree-comfy/src_web/comfyui/base_node_mode_changer.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RgthreeBaseVirtualNodeConstructor } from "typings/rgthree.js";
2
+ import type {
3
+ LGraphNode as TLGraphNode,
4
+ IWidget,
5
+ SerializedLGraphNode,
6
+ } from "typings/litegraph.js";
7
+ import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js";
8
+ import { PassThroughFollowing } from "./utils.js";
9
+ import { wait } from "rgthree/common/shared_utils.js";
10
+
11
+ export class BaseNodeModeChanger extends BaseAnyInputConnectedNode {
12
+ override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
13
+
14
+ static collapsible = false;
15
+ override isVirtualNode = true;
16
+
17
+ // These Must be overriden
18
+ readonly modeOn: number = -1;
19
+ readonly modeOff: number = -1;
20
+
21
+ static "@toggleRestriction" = {
22
+ type: "combo",
23
+ values: ["default", "max one", "always one"],
24
+ };
25
+
26
+ constructor(title?: string) {
27
+ super(title);
28
+ this.properties["toggleRestriction"] = "default";
29
+ }
30
+
31
+ override onConstructed(): boolean {
32
+ wait(10).then(() => {
33
+ if (this.modeOn < 0 || this.modeOff < 0) {
34
+ throw new Error("modeOn and modeOff must be overridden.");
35
+ }
36
+ });
37
+ this.addOutput("OPT_CONNECTION", "*");
38
+ return super.onConstructed();
39
+ }
40
+
41
+ override configure(info: SerializedLGraphNode<TLGraphNode>): void {
42
+ // Patch a small issue (~14h) where multiple OPT_CONNECTIONS may have been created.
43
+ // https://github.com/rgthree/rgthree-comfy/issues/206
44
+ // TODO: This can probably be removed within a few weeks.
45
+ if (info.outputs?.length) {
46
+ info.outputs.length = 1;
47
+ }
48
+ super.configure(info);
49
+ }
50
+
51
+ override handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]) {
52
+ let changed = false;
53
+ for (const [index, node] of linkedNodes.entries()) {
54
+ let widget = this.widgets && this.widgets[index];
55
+ if (!widget) {
56
+ // When we add a widget, litegraph is going to mess up the size, so we
57
+ // store it so we can retrieve it in computeSize. Hacky..
58
+ (this as any)._tempWidth = this.size[0];
59
+ widget = this.addWidget("toggle", "", false, "", { on: "yes", off: "no" });
60
+ changed = true;
61
+ }
62
+ if (node) {
63
+ changed = this.setWidget(widget, node) || changed;
64
+ }
65
+ }
66
+ if (this.widgets && this.widgets.length > linkedNodes.length) {
67
+ this.widgets.length = linkedNodes.length;
68
+ changed = true;
69
+ }
70
+ return changed;
71
+ }
72
+
73
+ private setWidget(widget: IWidget, linkedNode: TLGraphNode, forceValue?: boolean) {
74
+ let changed = false;
75
+ const value = forceValue == null ? linkedNode.mode === this.modeOn : forceValue;
76
+ let name = `Enable ${linkedNode.title}`;
77
+ // Need to set initally
78
+ if (widget.name !== name) {
79
+ widget.name = `Enable ${linkedNode.title}`;
80
+ widget.options = { on: "yes", off: "no" };
81
+ widget.value = value;
82
+ (widget as any).doModeChange = (forceValue?: boolean, skipOtherNodeCheck?: boolean) => {
83
+ let newValue = forceValue == null ? linkedNode.mode === this.modeOff : forceValue;
84
+ if (skipOtherNodeCheck !== true) {
85
+ if (newValue && this.properties?.["toggleRestriction"]?.includes(" one")) {
86
+ for (const widget of this.widgets) {
87
+ (widget as any).doModeChange(false, true);
88
+ }
89
+ } else if (!newValue && this.properties?.["toggleRestriction"] === "always one") {
90
+ newValue = this.widgets.every((w) => !w.value || w === widget);
91
+ }
92
+ }
93
+ linkedNode.mode = (newValue ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4;
94
+ widget.value = newValue;
95
+ };
96
+ widget.callback = () => {
97
+ (widget as any).doModeChange();
98
+ };
99
+ changed = true;
100
+ }
101
+ if (forceValue != null) {
102
+ const newMode = (forceValue ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4;
103
+ if (linkedNode.mode !== newMode) {
104
+ linkedNode.mode = newMode;
105
+ changed = true;
106
+ }
107
+ }
108
+ return changed;
109
+ }
110
+
111
+ forceWidgetOff(widget: IWidget, skipOtherNodeCheck?: boolean) {
112
+ (widget as any).doModeChange(false, skipOtherNodeCheck);
113
+ }
114
+ forceWidgetOn(widget: IWidget, skipOtherNodeCheck?: boolean) {
115
+ (widget as any).doModeChange(true, skipOtherNodeCheck);
116
+ }
117
+ forceWidgetToggle(widget: IWidget, skipOtherNodeCheck?: boolean) {
118
+ (widget as any).doModeChange(!widget.value, skipOtherNodeCheck);
119
+ }
120
+ }
rgthree-comfy/src_web/comfyui/base_power_prompt.ts ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "scripts/api.js";
2
+ import type {
3
+ LLink,
4
+ IComboWidget,
5
+ LGraphNode,
6
+ INodeOutputSlot,
7
+ INodeInputSlot,
8
+ IWidget,
9
+ SerializedLGraphNode,
10
+ } from "typings/litegraph.js";
11
+ import type { ComfyObjectInfo, ComfyGraphNode } from "typings/comfy.js";
12
+ import { wait } from "rgthree/common/shared_utils.js";
13
+ import { rgthree } from "./rgthree.js";
14
+
15
+ /** Wraps a node instance keeping closure without mucking the finicky types. */
16
+ export class PowerPrompt {
17
+ readonly isSimple: boolean;
18
+ readonly node: ComfyGraphNode;
19
+ readonly promptEl: HTMLTextAreaElement;
20
+ nodeData: ComfyObjectInfo;
21
+ readonly combos: { [key: string]: IComboWidget } = {};
22
+ readonly combosValues: { [key: string]: string[] } = {};
23
+ boundOnFreshNodeDefs!: (event: CustomEvent) => void;
24
+
25
+ private configuring = false;
26
+
27
+ constructor(node: ComfyGraphNode, nodeData: ComfyObjectInfo) {
28
+ this.node = node;
29
+ this.node.properties = this.node.properties || {};
30
+
31
+ this.node.properties["combos_filter"] = "";
32
+
33
+ this.nodeData = nodeData;
34
+ this.isSimple = this.nodeData.name.includes("Simple");
35
+
36
+ this.promptEl = (node.widgets[0]! as any).inputEl;
37
+ this.addAndHandleKeyboardLoraEditWeight();
38
+
39
+ this.patchNodeRefresh();
40
+
41
+ const oldConfigure = this.node.configure;
42
+ this.node.configure = (info: SerializedLGraphNode) => {
43
+ this.configuring = true;
44
+ oldConfigure?.apply(this.node, [info]);
45
+ this.configuring = false;
46
+ };
47
+
48
+ const oldOnConnectionsChange = this.node.onConnectionsChange;
49
+ this.node.onConnectionsChange = (
50
+ type: number,
51
+ slotIndex: number,
52
+ isConnected: boolean,
53
+ link_info: LLink,
54
+ _ioSlot: INodeOutputSlot | INodeInputSlot,
55
+ ) => {
56
+ oldOnConnectionsChange?.apply(this.node, [type, slotIndex, isConnected, link_info, _ioSlot]);
57
+ this.onNodeConnectionsChange(type, slotIndex, isConnected, link_info, _ioSlot);
58
+ };
59
+
60
+ const oldOnConnectInput = this.node.onConnectInput;
61
+ this.node.onConnectInput = (
62
+ inputIndex: number,
63
+ outputType: INodeOutputSlot["type"],
64
+ outputSlot: INodeOutputSlot,
65
+ outputNode: LGraphNode,
66
+ outputIndex: number,
67
+ ) => {
68
+ let canConnect = true;
69
+ if (oldOnConnectInput) {
70
+ canConnect = oldOnConnectInput.apply(this.node, [
71
+ inputIndex,
72
+ outputType,
73
+ outputSlot,
74
+ outputNode,
75
+ outputIndex,
76
+ ]);
77
+ }
78
+ return (
79
+ this.configuring ||
80
+ rgthree.loadingApiJson ||
81
+ (canConnect && !this.node.inputs[inputIndex]!.disabled)
82
+ );
83
+ };
84
+
85
+ const oldOnConnectOutput = this.node.onConnectOutput;
86
+ this.node.onConnectOutput = (
87
+ outputIndex: number,
88
+ inputType: INodeInputSlot["type"],
89
+ inputSlot: INodeInputSlot,
90
+ inputNode: LGraphNode,
91
+ inputIndex: number,
92
+ ) => {
93
+ let canConnect = true;
94
+ if (oldOnConnectOutput) {
95
+ canConnect = oldOnConnectOutput?.apply(this.node, [
96
+ outputIndex,
97
+ inputType,
98
+ inputSlot,
99
+ inputNode,
100
+ inputIndex,
101
+ ]);
102
+ }
103
+ return (
104
+ this.configuring ||
105
+ rgthree.loadingApiJson ||
106
+ (canConnect && !this.node.outputs[outputIndex]!.disabled)
107
+ );
108
+ };
109
+
110
+ const onPropertyChanged = this.node.onPropertyChanged;
111
+ this.node.onPropertyChanged = (property: string, value: any, prevValue: any) => {
112
+ onPropertyChanged && onPropertyChanged.call(this, property, value, prevValue);
113
+ if (property === "combos_filter") {
114
+ this.refreshCombos(this.nodeData);
115
+ }
116
+ };
117
+
118
+ // Strip all widgets but prompt (we'll re-add them in refreshCombos)
119
+ // this.node.widgets.splice(1);
120
+ for (let i = this.node.widgets.length - 1; i >= 0; i--) {
121
+ if (this.shouldRemoveServerWidget(this.node.widgets[i]!)) {
122
+ this.node.widgets.splice(i, 1);
123
+ }
124
+ }
125
+
126
+ this.refreshCombos(nodeData);
127
+ setTimeout(() => {
128
+ this.stabilizeInputsOutputs();
129
+ }, 32);
130
+ }
131
+
132
+ /**
133
+ * Cleans up optional out puts when we don't have the optional input. Purely a vanity function.
134
+ */
135
+ onNodeConnectionsChange(
136
+ _type: number,
137
+ _slotIndex: number,
138
+ _isConnected: boolean,
139
+ _linkInfo: LLink,
140
+ _ioSlot: INodeOutputSlot | INodeInputSlot,
141
+ ) {
142
+ this.stabilizeInputsOutputs();
143
+ }
144
+
145
+ private stabilizeInputsOutputs() {
146
+ // If we are currently "configuring" then skip this stabilization. The connected nodes may
147
+ // not yet be configured.
148
+ if (this.configuring || rgthree.loadingApiJson) {
149
+ return;
150
+ }
151
+ // If our first input is connected, then we can show the proper output.
152
+ const clipLinked = this.node.inputs.some((i) => i.name.includes("clip") && !!i.link);
153
+ const modelLinked = this.node.inputs.some((i) => i.name.includes("model") && !!i.link);
154
+ for (const output of this.node.outputs) {
155
+ const type = (output.type as string).toLowerCase();
156
+ if (type.includes("model")) {
157
+ output.disabled = !modelLinked;
158
+ } else if (type.includes("conditioning")) {
159
+ output.disabled = !clipLinked;
160
+ } else if (type.includes("clip")) {
161
+ output.disabled = !clipLinked;
162
+ } else if (type.includes("string")) {
163
+ // Our text prompt is always enabled, but let's color it so it stands out
164
+ // if the others are disabled. #7F7 is Litegraph's default.
165
+ output.color_off = "#7F7";
166
+ output.color_on = "#7F7";
167
+ }
168
+ if (output.disabled) {
169
+ // this.node.disconnectOutput(index);
170
+ }
171
+ }
172
+ }
173
+
174
+ onFreshNodeDefs(event: CustomEvent) {
175
+ this.refreshCombos(event.detail[this.nodeData.name]);
176
+ }
177
+
178
+ shouldRemoveServerWidget(widget: IWidget) {
179
+ return (
180
+ widget.name?.startsWith("insert_") ||
181
+ widget.name?.startsWith("target_") ||
182
+ widget.name?.startsWith("crop_") ||
183
+ widget.name?.startsWith("values_")
184
+ );
185
+ }
186
+
187
+ refreshCombos(nodeData: ComfyObjectInfo) {
188
+ this.nodeData = nodeData;
189
+ let filter: RegExp | null = null;
190
+ if (this.node.properties["combos_filter"]?.trim()) {
191
+ try {
192
+ filter = new RegExp(this.node.properties["combos_filter"].trim(), "i");
193
+ } catch (e) {
194
+ console.error(`Could not parse "${filter}" for Regular Expression`, e);
195
+ filter = null;
196
+ }
197
+ }
198
+
199
+ // Add the combo for hidden inputs of nodeData
200
+ let data = Object.assign(
201
+ {},
202
+ this.nodeData.input?.optional || {},
203
+ this.nodeData.input?.hidden || {},
204
+ );
205
+
206
+ for (const [key, value] of Object.entries(data)) {
207
+ //Object.entries(this.nodeData.input?.hidden || {})) {
208
+ if (Array.isArray(value[0])) {
209
+ let values = value[0] as string[];
210
+ if (key.startsWith("insert")) {
211
+ values = filter
212
+ ? values.filter(
213
+ (v, i) => i < 1 || (i == 1 && v.match(/^disable\s[a-z]/i)) || filter?.test(v),
214
+ )
215
+ : values;
216
+ const shouldShow =
217
+ values.length > 2 || (values.length > 1 && !values[1]!.match(/^disable\s[a-z]/i));
218
+ if (shouldShow) {
219
+ if (!this.combos[key]) {
220
+ this.combos[key] = this.node.addWidget(
221
+ "combo",
222
+ key,
223
+ values,
224
+ (selected) => {
225
+ if (selected !== values[0] && !selected.match(/^disable\s[a-z]/i)) {
226
+ // We wait a frame because if we use a keydown event to call, it'll wipe out
227
+ // the selection.
228
+ wait().then(() => {
229
+ if (key.includes("embedding")) {
230
+ this.insertSelectionText(`embedding:${selected}`);
231
+ } else if (key.includes("saved")) {
232
+ this.insertSelectionText(
233
+ this.combosValues[`values_${key}`]![values.indexOf(selected)]!,
234
+ );
235
+ } else if (key.includes("lora")) {
236
+ this.insertSelectionText(`<lora:${selected}:1.0>`);
237
+ }
238
+ this.combos[key]!.value = values[0];
239
+ });
240
+ }
241
+ },
242
+ {
243
+ values,
244
+ serialize: true, // Don't include this in prompt.
245
+ },
246
+ );
247
+ (this.combos[key]! as any).oldComputeSize = this.combos[key]!.computeSize;
248
+ let node = this.node;
249
+ this.combos[key]!.computeSize = function (width: number) {
250
+ const size = (this as any).oldComputeSize?.(width) || [
251
+ width,
252
+ LiteGraph.NODE_WIDGET_HEIGHT,
253
+ ];
254
+ if (this === node.widgets[node.widgets.length - 1]) {
255
+ size[1] += 10;
256
+ }
257
+ return size;
258
+ };
259
+ }
260
+ this.combos[key]!.options!.values = values;
261
+ this.combos[key]!.value = values[0];
262
+ } else if (!shouldShow && this.combos[key]) {
263
+ this.node.widgets.splice(this.node.widgets.indexOf(this.combos[key]!), 1);
264
+ delete this.combos[key];
265
+ }
266
+ } else if (key.startsWith("values")) {
267
+ this.combosValues[key] = values;
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ insertSelectionText(text: string) {
274
+ if (!this.promptEl) {
275
+ console.error("Asked to insert text, but no textbox found.");
276
+ return;
277
+ }
278
+ let prompt = this.promptEl.value;
279
+ // Use selectionEnd as the split; if we have highlighted text, then we likely don't want to
280
+ // overwrite it (we could have just deleted it more easily).
281
+ let first = prompt.substring(0, this.promptEl.selectionEnd).replace(/ +$/, "");
282
+ first = first + (["\n"].includes(first[first.length - 1]!) ? "" : first.length ? " " : "");
283
+ let second = prompt.substring(this.promptEl.selectionEnd).replace(/^ +/, "");
284
+ second = (["\n"].includes(second[0]!) ? "" : second.length ? " " : "") + second;
285
+ this.promptEl.value = first + text + second;
286
+ this.promptEl.focus();
287
+ this.promptEl.selectionStart = first.length;
288
+ this.promptEl.selectionEnd = first.length + text.length;
289
+ }
290
+
291
+ /**
292
+ * Adds a keydown event listener to our prompt so we can see if we're using the
293
+ * ctrl/cmd + up/down arrows shortcut. This kind of competes with the core extension
294
+ * "Comfy.EditAttention" but since that only handles parenthesis and listens on window, we should
295
+ * be able to intercept and cancel the bubble if we're doing the same action within the lora tag.
296
+ */
297
+ addAndHandleKeyboardLoraEditWeight() {
298
+ this.promptEl.addEventListener("keydown", (event: KeyboardEvent) => {
299
+ // If we're not doing a ctrl/cmd + arrow key, then bail.
300
+ if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
301
+ if (!event.ctrlKey && !event.metaKey) return;
302
+ // Unfortunately, we can't see Comfy.EditAttention delta in settings, so we hardcode to 0.01.
303
+ // We can acutally do better too, let's make it .1 by default, and .01 if also holding shift.
304
+ const delta = event.shiftKey ? 0.01 : 0.1;
305
+
306
+ let start = this.promptEl.selectionStart;
307
+ let end = this.promptEl.selectionEnd;
308
+ let fullText = this.promptEl.value;
309
+ let selectedText = fullText.substring(start, end);
310
+
311
+ // We don't care about fully rewriting Comfy.EditAttention, we just want to see if our
312
+ // selected text is a lora, which will always start with "<lora:". So work backwards until we
313
+ // find something that we know can't be a lora, or a "<".
314
+ if (!selectedText) {
315
+ const stopOn = "<>()\r\n\t"; // Allow spaces, since they can be in the filename
316
+ if (fullText[start] == ">") {
317
+ start -= 2;
318
+ end -= 2;
319
+ }
320
+ if (fullText[end - 1] == "<") {
321
+ start += 2;
322
+ end += 2;
323
+ }
324
+ while (!stopOn.includes(fullText[start]!) && start > 0) {
325
+ start--;
326
+ }
327
+ while (!stopOn.includes(fullText[end - 1]!) && end < fullText.length) {
328
+ end++;
329
+ }
330
+ selectedText = fullText.substring(start, end);
331
+ }
332
+
333
+ // Bail if this isn't a lora.
334
+ if (!selectedText.startsWith("<lora:") || !selectedText.endsWith(">")) {
335
+ return;
336
+ }
337
+
338
+ let weight = Number(selectedText.match(/:(-?\d*(\.\d*)?)>$/)?.[1]) ?? 1;
339
+ weight += event.key === "ArrowUp" ? delta : -delta;
340
+ const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`);
341
+
342
+ // Handle the new value and cancel the bubble so Comfy.EditAttention doesn't also try.
343
+ this.promptEl.setRangeText(updatedText, start, end, "select");
344
+ event.preventDefault();
345
+ event.stopPropagation();
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Patches over api.getNodeDefs in comfy's api.js to fire a custom event that we can listen to
351
+ * here and manually refresh our combos when a request comes in to fetch the node data; which
352
+ * only happens once at startup (but before custom nodes js runs), and then after clicking
353
+ * the "Refresh" button in the floating menu, which is what we care about.
354
+ */
355
+ patchNodeRefresh() {
356
+ this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this);
357
+ api.addEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
358
+ const oldNodeRemoved = this.node.onRemoved;
359
+ this.node.onRemoved = () => {
360
+ oldNodeRemoved?.call(this.node);
361
+ api.removeEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
362
+ };
363
+ }
364
+ }
rgthree-comfy/src_web/comfyui/bookmark.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { RgthreeBaseVirtualNode } from "./base_node.js";
3
+ import { SERVICE as KEY_EVENT_SERVICE } from "./services/key_events_services.js";
4
+ import { NodeTypesString } from "./constants.js";
5
+ import type {
6
+ LGraph,
7
+ LGraphCanvas,
8
+ INumberWidget,
9
+ LGraphNode,
10
+ Vector2,
11
+ } from "typings/litegraph.js";
12
+ import { getClosestOrSelf, queryOne } from "rgthree/common/utils_dom.js";
13
+
14
+ /**
15
+ * A bookmark node. Can be placed anywhere in the workflow, and given a shortcut key that will
16
+ * navigate to that node, with it in the top-left corner.
17
+ */
18
+ export class Bookmark extends RgthreeBaseVirtualNode {
19
+ static override type = NodeTypesString.BOOKMARK;
20
+ static override title = NodeTypesString.BOOKMARK;
21
+ override comfyClass = NodeTypesString.BOOKMARK;
22
+
23
+ // Really silly, but Litegraph assumes we have at least one input/output... so we need to
24
+ // counteract it's computeSize calculation by offsetting the start.
25
+ static slot_start_y = -20;
26
+
27
+ // LiteGraph adds mroe spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
28
+ // override it with a setter and re-set it measured exactly as we want.
29
+ ___collapsed_width: number = 0;
30
+
31
+ override isVirtualNode = true;
32
+ override serialize_widgets = true;
33
+
34
+ //@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
35
+ override get _collapsed_width() {
36
+ return this.___collapsed_width;
37
+ }
38
+
39
+ override set _collapsed_width(width: number) {
40
+ const canvas = app.canvas as LGraphCanvas;
41
+ const ctx = canvas.canvas.getContext("2d")!;
42
+ const oldFont = ctx.font;
43
+ ctx.font = canvas.title_text_font;
44
+ this.___collapsed_width = 40 + ctx.measureText(this.title).width;
45
+ ctx.font = oldFont;
46
+ }
47
+
48
+ readonly keypressBound;
49
+
50
+ constructor(title = Bookmark.title) {
51
+ super(title);
52
+ const nextShortcutChar = getNextShortcut();
53
+ this.addWidget(
54
+ "text",
55
+ "shortcut_key",
56
+ nextShortcutChar,
57
+ (value: string, ...args) => {
58
+ value = value.trim()[0] || "1";
59
+ },
60
+ {
61
+ y: 8,
62
+ },
63
+ );
64
+ this.addWidget<INumberWidget>("number", "zoom", 1, (value: number) => {}, {
65
+ y: 8 + LiteGraph.NODE_WIDGET_HEIGHT + 4,
66
+ max: 2,
67
+ min: 0.5,
68
+ precision: 2,
69
+ });
70
+ this.keypressBound = this.onKeypress.bind(this);
71
+ this.title = "🔖";
72
+ this.onConstructed();
73
+ }
74
+
75
+ // override computeSize(out?: Vector2 | undefined): Vector2 {
76
+ // super.computeSize(out);
77
+ // const minHeight = (this.widgets?.length || 0) * (LiteGraph.NODE_WIDGET_HEIGHT + 4) + 16;
78
+ // this.size[1] = Math.max(minHeight, this.size[1]);
79
+ // }
80
+
81
+ get shortcutKey(): string {
82
+ return this.widgets[0]?.value?.toLocaleLowerCase() ?? "";
83
+ }
84
+
85
+ override onAdded(graph: LGraph): void {
86
+ KEY_EVENT_SERVICE.addEventListener("keydown", this.keypressBound as EventListener);
87
+ }
88
+
89
+ override onRemoved(): void {
90
+ KEY_EVENT_SERVICE.removeEventListener("keydown", this.keypressBound as EventListener);
91
+ }
92
+
93
+ onKeypress(event: CustomEvent<{ originalEvent: KeyboardEvent }>) {
94
+ const originalEvent = event.detail.originalEvent;
95
+ const target = (originalEvent.target as HTMLElement)!;
96
+ if (getClosestOrSelf(target, 'input,textarea,[contenteditable="true"]')) {
97
+ return;
98
+ }
99
+
100
+ // Only the shortcut keys are held down, otionally including "shift".
101
+ if (KEY_EVENT_SERVICE.areOnlyKeysDown(this.widgets[0]!.value, true)) {
102
+ this.canvasToBookmark();
103
+ originalEvent.preventDefault();
104
+ originalEvent.stopPropagation();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Called from LiteGraph's `processMouseDown` after it would invoke the input box for the
110
+ * shortcut_key, so we check if it exists and then add our own event listener so we can track the
111
+ * keys down for the user.
112
+ */
113
+ override onMouseDown(event: MouseEvent, pos: Vector2, graphCanvas: LGraphCanvas): void {
114
+ const input = queryOne<HTMLInputElement>(".graphdialog > input.value");
115
+ if (input && input.value === this.widgets[0]?.value) {
116
+ input.addEventListener("keydown", (e) => {
117
+ // ComfyUI swallows keydown on inputs, so we need to call out to rgthree to use downkeys.
118
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(e);
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ input.value = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" + ");
122
+ });
123
+ }
124
+ }
125
+
126
+ canvasToBookmark() {
127
+ const canvas = app.canvas as LGraphCanvas;
128
+ // ComfyUI seemed to break us again, but couldn't repro. No reason to not check, I guess.
129
+ // https://github.com/rgthree/rgthree-comfy/issues/71
130
+ if (canvas?.ds?.offset) {
131
+ canvas.ds.offset[0] = -this.pos[0] + 16;
132
+ canvas.ds.offset[1] = -this.pos[1] + 40;
133
+ }
134
+ if (canvas?.ds?.scale != null) {
135
+ canvas.ds.scale = Number(this.widgets[1]!.value || 1);
136
+ }
137
+ canvas.setDirty(true, true);
138
+ }
139
+ }
140
+
141
+ app.registerExtension({
142
+ name: "rgthree.Bookmark",
143
+ registerCustomNodes() {
144
+ Bookmark.setUp();
145
+ },
146
+ });
147
+
148
+ function isBookmark(node: LGraphNode): node is Bookmark {
149
+ return node.type === NodeTypesString.BOOKMARK;
150
+ }
151
+
152
+ function getExistingShortcuts() {
153
+ const graph: LGraph = app.graph;
154
+ const bookmarkNodes = graph._nodes.filter(isBookmark);
155
+ const usedShortcuts = new Set(bookmarkNodes.map((n) => n.shortcutKey));
156
+ return usedShortcuts;
157
+ }
158
+
159
+ const SHORTCUT_DEFAULTS = "1234567890abcdefghijklmnopqrstuvwxyz".split("");
160
+ function getNextShortcut() {
161
+ const existingShortcuts = getExistingShortcuts();
162
+ return SHORTCUT_DEFAULTS.find((char) => !existingShortcuts.has(char)) ?? "1";
163
+ }
rgthree-comfy/src_web/comfyui/bypasser.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { BaseNodeModeChanger } from "./base_node_mode_changer.js";
3
+ import { NodeTypesString } from "./constants.js";
4
+ import type { LGraphNode } from "typings/litegraph.js";
5
+
6
+ const MODE_BYPASS = 4;
7
+ const MODE_ALWAYS = 0;
8
+
9
+ class BypasserNode extends BaseNodeModeChanger {
10
+ static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
11
+
12
+ static override type = NodeTypesString.FAST_BYPASSER;
13
+ static override title = NodeTypesString.FAST_BYPASSER;
14
+ override comfyClass = NodeTypesString.FAST_BYPASSER;
15
+
16
+ override readonly modeOn = MODE_ALWAYS;
17
+ override readonly modeOff = MODE_BYPASS;
18
+
19
+ constructor(title = BypasserNode.title) {
20
+ super(title);
21
+ this.onConstructed();
22
+ }
23
+
24
+ override async handleAction(action: string) {
25
+ if (action === "Bypass all") {
26
+ for (const widget of this.widgets) {
27
+ this.forceWidgetOff(widget, true);
28
+ }
29
+ } else if (action === "Enable all") {
30
+ for (const widget of this.widgets) {
31
+ this.forceWidgetOn(widget, true);
32
+ }
33
+ } else if (action === "Toggle all") {
34
+ for (const widget of this.widgets) {
35
+ this.forceWidgetToggle(widget, true);
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ app.registerExtension({
42
+ name: "rgthree.Bypasser",
43
+ registerCustomNodes() {
44
+ BypasserNode.setUp();
45
+ },
46
+ loadedGraphNode(node: LGraphNode) {
47
+ if (node.type == BypasserNode.title) {
48
+ (node as any)._tempWidth = node.size[0];
49
+ }
50
+ },
51
+ });
rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { ComfyButtonGroup } from "scripts/ui/components/buttonGroup.js";
3
+ import { ComfyButton } from "scripts/ui/components/button.js";
4
+ import { iconGear, iconStarFilled, logoRgthree } from "rgthree/common/media/svgs.js";
5
+ import { createElement, empty, queryOne } from "rgthree/common/utils_dom.js";
6
+ import { SERVICE as BOOKMARKS_SERVICE } from "./services/bookmarks_services.js";
7
+ import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
8
+ import { ComfyPopup } from "scripts/ui/components/popup.js";
9
+ import { RgthreeConfigDialog } from "./config.js";
10
+
11
+ let rgthreeButtonGroup: ComfyButtonGroup | null = null;
12
+
13
+ function addRgthreeTopBarButtons() {
14
+ if (!CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.enabled")) {
15
+ if (rgthreeButtonGroup?.element?.parentElement) {
16
+ rgthreeButtonGroup.element.parentElement.removeChild(rgthreeButtonGroup.element);
17
+ }
18
+ return;
19
+ } else if (rgthreeButtonGroup) {
20
+ app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
21
+ return;
22
+ }
23
+
24
+ const buttons = [];
25
+
26
+ const rgthreeButton = new ComfyButton({
27
+ icon: "rgthree",
28
+ tooltip: "rgthree-comfy",
29
+ // content: 'rgthree-comfy',
30
+ app,
31
+ enabled: true,
32
+ classList: "comfyui-button comfyui-menu-mobile-collapse primary",
33
+ });
34
+ buttons.push(rgthreeButton);
35
+ rgthreeButton.iconElement.style.width = "1.2rem";
36
+ rgthreeButton.iconElement.innerHTML = logoRgthree;
37
+ rgthreeButton.withPopup(
38
+ new ComfyPopup(
39
+ { target: rgthreeButton.element, classList: "rgthree-top-menu" },
40
+ createElement("menu", {
41
+ children: [
42
+ createElement("li", {
43
+ child: createElement("button.rgthree-button-reset", {
44
+ html: iconGear + "Settings (rgthree-comfy)",
45
+ onclick: () => new RgthreeConfigDialog().show(),
46
+ }),
47
+ }),
48
+ createElement("li", {
49
+ child: createElement("button.rgthree-button-reset", {
50
+ html: iconStarFilled + "Star on Github",
51
+ onclick: () => window.open("https://github.com/rgthree/rgthree-comfy", "_blank"),
52
+ }),
53
+ }),
54
+ ],
55
+ }),
56
+ ),
57
+ "click",
58
+ );
59
+
60
+ if (CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.button_bookmarks.enabled")) {
61
+ const bookmarksListEl = createElement("menu");
62
+ bookmarksListEl.appendChild(
63
+ createElement("li.rgthree-message", {
64
+ child: createElement("span", { text: "No bookmarks in current workflow." }),
65
+ }),
66
+ );
67
+ const bookmarksButton = new ComfyButton({
68
+ icon: "bookmark",
69
+ tooltip: "Workflow Bookmarks (rgthree-comfy)",
70
+ app,
71
+ });
72
+ const bookmarksPopup = new ComfyPopup(
73
+ { target: bookmarksButton.element, classList: "rgthree-top-menu" },
74
+ bookmarksListEl,
75
+ );
76
+ bookmarksPopup.addEventListener("open", () => {
77
+ const bookmarks = BOOKMARKS_SERVICE.getCurrentBookmarks();
78
+ empty(bookmarksListEl);
79
+ if (bookmarks.length) {
80
+ for (const b of bookmarks) {
81
+ bookmarksListEl.appendChild(
82
+ createElement("li", {
83
+ child: createElement("button.rgthree-button-reset", {
84
+ text: `[${b.shortcutKey}] ${b.title}`,
85
+ onclick: () => {
86
+ b.canvasToBookmark();
87
+ },
88
+ }),
89
+ }),
90
+ );
91
+ }
92
+ } else {
93
+ bookmarksListEl.appendChild(
94
+ createElement("li.rgthree-message", {
95
+ child: createElement("span", { text: "No bookmarks in current workflow." }),
96
+ }),
97
+ );
98
+ }
99
+ bookmarksPopup.update();
100
+ });
101
+ bookmarksButton.withPopup(bookmarksPopup, "hover");
102
+ buttons.push(bookmarksButton);
103
+ }
104
+
105
+ rgthreeButtonGroup = new ComfyButtonGroup(...buttons);
106
+ app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
107
+ }
108
+
109
+ app.registerExtension({
110
+ name: "rgthree.TopMenu",
111
+ async setup() {
112
+ addRgthreeTopBarButtons();
113
+
114
+ CONFIG_SERVICE.addEventListener("config-change", ((e: CustomEvent) => {
115
+ if (e.detail?.key?.includes("features.comfy_top_bar_menu")) {
116
+ addRgthreeTopBarButtons();
117
+ }
118
+ }) as EventListener);
119
+ },
120
+ });
rgthree-comfy/src_web/comfyui/config.ts ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { RgthreeDialog, RgthreeDialogOptions } from "rgthree/common/dialog.js";
3
+ import { createElement as $el, query as $$ } from "rgthree/common/utils_dom.js";
4
+ import { checkmark, logoRgthree } from "rgthree/common/media/svgs.js";
5
+ import { LogLevel, rgthree } from "./rgthree.js";
6
+ import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
7
+
8
+ /** Types of config used as a hint for the form handling. */
9
+ enum ConfigType {
10
+ UNKNOWN,
11
+ BOOLEAN,
12
+ STRING,
13
+ NUMBER,
14
+ ARRAY,
15
+ }
16
+
17
+ enum ConfigInputType {
18
+ UNKNOWN,
19
+ CHECKLIST, // Which is a multiselect array.
20
+ }
21
+
22
+ const TYPE_TO_STRING = {
23
+ [ConfigType.UNKNOWN]: "unknown",
24
+ [ConfigType.BOOLEAN]: "boolean",
25
+ [ConfigType.STRING]: "string",
26
+ [ConfigType.NUMBER]: "number",
27
+ [ConfigType.ARRAY]: "array",
28
+ };
29
+
30
+ type ConfigurationSchema = {
31
+ key: string;
32
+ type: ConfigType;
33
+ label: string;
34
+ inputType?: ConfigInputType,
35
+ options?: string[] | number[] | ConfigurationSchemaOption[];
36
+ description?: string;
37
+ subconfig?: ConfigurationSchema[];
38
+ isDevOnly?: boolean;
39
+ onSave?: (value: any) => void;
40
+ };
41
+
42
+ type ConfigurationSchemaOption = { value: any; label: string };
43
+
44
+ /**
45
+ * A static schema of sorts to layout options found in the config.
46
+ */
47
+ const CONFIGURABLE: { [key: string]: ConfigurationSchema[] } = {
48
+ features: [
49
+ {
50
+ key: "features.progress_bar.enabled",
51
+ type: ConfigType.BOOLEAN,
52
+ label: "Prompt Progress Bar",
53
+ description: `Shows a minimal progress bar for nodes and steps at the top of the app.`,
54
+ subconfig: [
55
+ {
56
+ key: "features.progress_bar.height",
57
+ type: ConfigType.NUMBER,
58
+ label: "Height of the bar",
59
+ },
60
+ {
61
+ key: "features.progress_bar.position",
62
+ type: ConfigType.STRING,
63
+ label: "Position at top or bottom of window",
64
+ options: ["top", "bottom"],
65
+ },
66
+ ],
67
+ },
68
+ {
69
+ key: "features.import_individual_nodes.enabled",
70
+ type: ConfigType.BOOLEAN,
71
+ label: "Import Individual Nodes Widgets",
72
+ description:
73
+ "Dragging & Dropping a similar image/JSON workflow onto (most) current workflow nodes" +
74
+ "will allow you to import that workflow's node's widgets when it has the same " +
75
+ "id and type. This is useful when you have several images and you'd like to import just " +
76
+ "one part of a previous iteration, like a seed, or prompt.",
77
+ },
78
+ ],
79
+ menus: [
80
+ {
81
+ key: "features.comfy_top_bar_menu.enabled",
82
+ type: ConfigType.BOOLEAN,
83
+ label: "Enable Top Bar Menu",
84
+ description:
85
+ "Have quick access from ComfyUI's new top bar to rgthree-comfy bookmarks, settings " +
86
+ "(and more to come).",
87
+ },
88
+ {
89
+ key: "features.menu_queue_selected_nodes",
90
+ type: ConfigType.BOOLEAN,
91
+ label: "Show 'Queue Selected Output Nodes'",
92
+ description:
93
+ "Will show a menu item in the right-click context menus to queue (only) the selected " +
94
+ "output nodes.",
95
+ },
96
+ {
97
+ key: "features.menu_auto_nest.subdirs",
98
+ type: ConfigType.BOOLEAN,
99
+ label: "Auto Nest Subdirectories in Menus",
100
+ description:
101
+ "When a large, flat list of values contain sub-directories, auto nest them. (Like, for " +
102
+ "a large list of checkpoints).",
103
+ subconfig: [
104
+ {
105
+ key: "features.menu_auto_nest.threshold",
106
+ type: ConfigType.NUMBER,
107
+ label: "Number of items needed to trigger nesting.",
108
+ },
109
+ ],
110
+ },
111
+ {
112
+ key: "features.menu_bookmarks.enabled",
113
+ type: ConfigType.BOOLEAN,
114
+ label: "Show Bookmarks in context menu",
115
+ description: "Will list bookmarks in the rgthree-comfy right-click context menu.",
116
+ },
117
+ ],
118
+ groups: [
119
+ {
120
+ key: "features.group_header_fast_toggle.enabled",
121
+ type: ConfigType.BOOLEAN,
122
+ label: "Show fast toggles in Group Headers",
123
+ description: "Show quick toggles in Groups' Headers to quickly mute, bypass or queue.",
124
+ subconfig: [
125
+ {
126
+ key: "features.group_header_fast_toggle.toggles",
127
+ type: ConfigType.ARRAY,
128
+ label: "Which toggles to show.",
129
+ inputType: ConfigInputType.CHECKLIST,
130
+ options: [
131
+ { value: "queue", label: "queue" },
132
+ { value: "bypass", label: "bypass" },
133
+ { value: "mute", label: "mute" },
134
+ ],
135
+ },
136
+ {
137
+ key: "features.group_header_fast_toggle.show",
138
+ type: ConfigType.STRING,
139
+ label: "When to show them.",
140
+ options: [
141
+ { value: "hover", label: "on hover" },
142
+ { value: "always", label: "always" },
143
+ ],
144
+ },
145
+ ],
146
+ },
147
+ ],
148
+ advanced: [
149
+ {
150
+ key: "features.show_alerts_for_corrupt_workflows",
151
+ type: ConfigType.BOOLEAN,
152
+ label: "Detect Corrupt Workflows",
153
+ description:
154
+ "Will show a message at the top of the screen when loading a workflow that has " +
155
+ "corrupt linking data.",
156
+ },
157
+ {
158
+ key: "log_level",
159
+ type: ConfigType.STRING,
160
+ label: "Log level for browser dev console.",
161
+ description:
162
+ "Further down the list, the more verbose logs to the console will be. For instance, " +
163
+ "selecting 'IMPORTANT' means only important message will be logged to the browser " +
164
+ "console, while selecting 'WARN' will log all messages at or higher than WARN, including " +
165
+ "'ERROR' and 'IMPORTANT' etc.",
166
+ options: ["IMPORTANT", "ERROR", "WARN", "INFO", "DEBUG", "DEV"],
167
+ isDevOnly: true,
168
+ onSave: function (value: LogLevel) {
169
+ rgthree.setLogLevel(value);
170
+ },
171
+ },
172
+ {
173
+ key: "features.invoke_extensions_async.node_created",
174
+ type: ConfigType.BOOLEAN,
175
+ label: "Allow other extensions to call nodeCreated on rgthree-nodes.",
176
+ isDevOnly: true,
177
+ description:
178
+ "Do not disable unless you are having trouble (and then file an issue at rgthree-comfy)." +
179
+ "Prior to Apr 2024 it was not possible for other extensions to invoke their nodeCreated " +
180
+ "event on some rgthree-comfy nodes. Now it's possible and this option is only here in " +
181
+ "for easy if something is wrong.",
182
+ },
183
+ ],
184
+ };
185
+
186
+ /**
187
+ * Creates a new fieldrow for main or sub configuration items.
188
+ */
189
+ function fieldrow(item: ConfigurationSchema) {
190
+ const initialValue = CONFIG_SERVICE.getConfigValue(item.key);
191
+ const container = $el(`div.fieldrow.-type-${TYPE_TO_STRING[item.type]}`, {
192
+ dataset: {
193
+ name: item.key,
194
+ initial: initialValue,
195
+ type: item.type,
196
+ },
197
+ });
198
+
199
+ $el(`label[for="${item.key}"]`, {
200
+ children: [
201
+ $el(`span[text="${item.label}"]`),
202
+ item.description ? $el("small", { html: item.description }) : null,
203
+ ],
204
+ parent: container,
205
+ });
206
+
207
+ let input;
208
+ if (item.options?.length) {
209
+ if (item.inputType === ConfigInputType.CHECKLIST) {
210
+ const initialValueList = initialValue || [];
211
+ input = $el<HTMLSelectElement>(`fieldset.rgthree-checklist-group[id="${item.key}"]`, {
212
+ parent: container,
213
+ children: item.options.map((o) => {
214
+ const label = (o as ConfigurationSchemaOption).label || String(o);
215
+ const value = (o as ConfigurationSchemaOption).value || o;
216
+ const id = `${item.key}_${value}`;
217
+ return $el<HTMLSpanElement>(`span.rgthree-checklist-item`, {
218
+ children: [
219
+ $el<HTMLInputElement>(`input[type="checkbox"][value="${value}"]`, {
220
+ id,
221
+ checked: initialValueList.includes(value),
222
+ }),
223
+ $el<HTMLInputElement>(`label`, {
224
+ for: id,
225
+ text: label,
226
+ })
227
+ ]
228
+ });
229
+ }),
230
+ });
231
+ } else {
232
+ input = $el<HTMLSelectElement>(`select[id="${item.key}"]`, {
233
+ parent: container,
234
+ children: item.options.map((o) => {
235
+ const label = (o as ConfigurationSchemaOption).label || String(o);
236
+ const value = (o as ConfigurationSchemaOption).value || o;
237
+ const valueSerialized = JSON.stringify({ value: value });
238
+ return $el<HTMLOptionElement>(`option[value="${valueSerialized}"]`, {
239
+ text: label,
240
+ selected: valueSerialized === JSON.stringify({ value: initialValue }),
241
+ });
242
+ }),
243
+ });
244
+ }
245
+ } else if (item.type === ConfigType.BOOLEAN) {
246
+ container.classList.toggle("-checked", !!initialValue);
247
+ input = $el<HTMLInputElement>(`input[type="checkbox"][id="${item.key}"]`, {
248
+ parent: container,
249
+ checked: initialValue,
250
+ });
251
+ } else {
252
+ input = $el(`input[id="${item.key}"]`, {
253
+ parent: container,
254
+ value: initialValue,
255
+ });
256
+ }
257
+ $el("div.fieldrow-value", { children: [input], parent: container });
258
+ return container;
259
+ }
260
+
261
+ /**
262
+ * A dialog to edit rgthree-comfy settings and config.
263
+ */
264
+ export class RgthreeConfigDialog extends RgthreeDialog {
265
+ constructor() {
266
+ const content = $el("div");
267
+
268
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["features"]!, "Features"));
269
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["menus"]!, "Menus"));
270
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["groups"]!, "Groups"));
271
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["advanced"]!, "Advanced"));
272
+
273
+ content.addEventListener("input", (e) => {
274
+ const changed = this.getChangedFormData();
275
+ ($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
276
+ !Object.keys(changed).length;
277
+ });
278
+ content.addEventListener("change", (e) => {
279
+ const changed = this.getChangedFormData();
280
+ ($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
281
+ !Object.keys(changed).length;
282
+ });
283
+
284
+ const dialogOptions: RgthreeDialogOptions = {
285
+ class: "-iconed -settings",
286
+ title: logoRgthree + `<h2>Settings - rgthree-comfy</h2>`,
287
+ content,
288
+ onBeforeClose: () => {
289
+ const changed = this.getChangedFormData();
290
+ if (Object.keys(changed).length) {
291
+ return confirm("Looks like there are unsaved changes. Are you sure you want close?");
292
+ }
293
+ return true;
294
+ },
295
+ buttons: [
296
+ {
297
+ label: "Save",
298
+ disabled: true,
299
+ className: "rgthree-button save-button -blue",
300
+ callback: async (e) => {
301
+ const changed = this.getChangedFormData();
302
+ if (!Object.keys(changed).length) {
303
+ this.close();
304
+ return;
305
+ }
306
+ const success = await CONFIG_SERVICE.setConfigValues(changed);
307
+ if (success) {
308
+ for (const key of Object.keys(changed)) {
309
+ Object.values(CONFIGURABLE)
310
+ .flat()
311
+ .find((f) => f.key === key)
312
+ ?.onSave?.(changed[key]);
313
+ }
314
+ this.close();
315
+ rgthree.showMessage({
316
+ id: "config-success",
317
+ message: `${checkmark} Successfully saved rgthree-comfy settings!`,
318
+ timeout: 4000,
319
+ });
320
+ ($$(".save-button", this.element)[0] as HTMLButtonElement).disabled = true;
321
+ } else {
322
+ alert("There was an error saving rgthree-comfy configuration.");
323
+ }
324
+ },
325
+ },
326
+ ],
327
+ };
328
+ super(dialogOptions);
329
+ }
330
+
331
+ private static buildFieldset(datas: ConfigurationSchema[], label: string) {
332
+ const fieldset = $el(`fieldset`, { children: [$el(`legend[text="${label}"]`)] });
333
+ for (const data of datas) {
334
+ if (data.isDevOnly && !rgthree.isDevMode()) {
335
+ continue;
336
+ }
337
+ const container = $el("div.formrow");
338
+ container.appendChild(fieldrow(data));
339
+
340
+ if (data.subconfig) {
341
+ for (const subfeature of data.subconfig) {
342
+ container.appendChild(fieldrow(subfeature));
343
+ }
344
+ }
345
+ fieldset.appendChild(container);
346
+ }
347
+ return fieldset;
348
+ }
349
+
350
+ getChangedFormData() {
351
+ return $$("[data-name]", this.contentElement).reduce((acc: { [key: string]: any }, el) => {
352
+ const name = el.dataset["name"]!;
353
+ const type = el.dataset["type"]!;
354
+ const initialValue = CONFIG_SERVICE.getConfigValue(name);
355
+ let currentValueEl = $$("fieldset.rgthree-checklist-group, input, textarea, select", el)[0] as HTMLInputElement;
356
+ let currentValue: any = null;
357
+ if (type === String(ConfigType.BOOLEAN)) {
358
+ currentValue = currentValueEl.checked;
359
+ // Not sure I like this side effect in here, but it's easy to just do it now.
360
+ el.classList.toggle("-checked", currentValue);
361
+ } else {
362
+ currentValue = currentValueEl?.value;
363
+ if (currentValueEl.nodeName === "SELECT") {
364
+ currentValue = JSON.parse(currentValue).value;
365
+ } else if (currentValueEl.classList.contains('rgthree-checklist-group')) {
366
+ currentValue = [];
367
+ for (const check of $$<HTMLInputElement>('input[type="checkbox"]', currentValueEl)) {
368
+ if (check.checked) {
369
+ currentValue.push(check.value);
370
+ }
371
+ }
372
+ } else if (type === String(ConfigType.NUMBER)) {
373
+ currentValue = Number(currentValue) || initialValue;
374
+ }
375
+ }
376
+ if (JSON.stringify(currentValue) !== JSON.stringify(initialValue)) {
377
+ acc[name] = currentValue;
378
+ }
379
+ return acc;
380
+ }, {});
381
+ }
382
+ }
383
+
384
+ app.ui.settings.addSetting({
385
+ id: "rgthree.config",
386
+ name: "Open rgthree-comfy config",
387
+ type: () => {
388
+ // Adds a row to open the dialog from the ComfyUI settings.
389
+ return $el("tr.rgthree-comfyui-settings-row", {
390
+ children: [
391
+ $el("td", {
392
+ child: `<div>${logoRgthree} [rgthree-comfy] configuration / settings</div>`,
393
+ }),
394
+ $el("td", {
395
+ child: $el('button.rgthree-button.-blue[text="rgthree-comfy settings"]', {
396
+ events: {
397
+ click: (e: PointerEvent) => {
398
+ new RgthreeConfigDialog().show();
399
+ },
400
+ },
401
+ }),
402
+ }),
403
+ ],
404
+ });
405
+ },
406
+ });
rgthree-comfy/src_web/comfyui/constants.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
2
+
3
+ export function addRgthree(str: string) {
4
+ return str + " (rgthree)";
5
+ }
6
+
7
+ export function stripRgthree(str: string) {
8
+ return str.replace(/\s*\(rgthree\)$/, "");
9
+ }
10
+
11
+ export const NodeTypesString = {
12
+ ANY_SWITCH: addRgthree("Any Switch"),
13
+ CONTEXT: addRgthree("Context"),
14
+ CONTEXT_BIG: addRgthree("Context Big"),
15
+ CONTEXT_SWITCH: addRgthree("Context Switch"),
16
+ CONTEXT_SWITCH_BIG: addRgthree("Context Switch Big"),
17
+ CONTEXT_MERGE: addRgthree("Context Merge"),
18
+ CONTEXT_MERGE_BIG: addRgthree("Context Merge Big"),
19
+ DYNAMIC_CONTEXT: addRgthree("Dynamic Context"),
20
+ DYNAMIC_CONTEXT_SWITCH: addRgthree("Dynamic Context Switch"),
21
+ DISPLAY_ANY: addRgthree("Display Any"),
22
+ NODE_MODE_RELAY: addRgthree("Mute / Bypass Relay"),
23
+ NODE_MODE_REPEATER: addRgthree("Mute / Bypass Repeater"),
24
+ FAST_MUTER: addRgthree("Fast Muter"),
25
+ FAST_BYPASSER: addRgthree("Fast Bypasser"),
26
+ FAST_GROUPS_MUTER: addRgthree("Fast Groups Muter"),
27
+ FAST_GROUPS_BYPASSER: addRgthree("Fast Groups Bypasser"),
28
+ FAST_ACTIONS_BUTTON: addRgthree("Fast Actions Button"),
29
+ LABEL: addRgthree("Label"),
30
+ POWER_PROMPT: addRgthree("Power Prompt"),
31
+ POWER_PROMPT_SIMPLE: addRgthree("Power Prompt - Simple"),
32
+ SDXL_EMPTY_LATENT_IMAGE: addRgthree("SDXL Empty Latent Image"),
33
+ SDXL_POWER_PROMPT_POSITIVE: addRgthree("SDXL Power Prompt - Positive"),
34
+ SDXL_POWER_PROMPT_NEGATIVE: addRgthree("SDXL Power Prompt - Simple / Negative"),
35
+ POWER_LORA_LOADER: addRgthree("Power Lora Loader"),
36
+ KSAMPLER_CONFIG: addRgthree("KSampler Config"),
37
+ NODE_COLLECTOR: addRgthree("Node Collector"),
38
+ REROUTE: addRgthree("Reroute"),
39
+ RANDOM_UNMUTER: addRgthree("Random Unmuter"),
40
+ SEED: addRgthree("Seed"),
41
+ BOOKMARK: addRgthree("Bookmark"),
42
+ IMAGE_COMPARER: addRgthree("Image Comparer"),
43
+ IMAGE_INSET_CROP: addRgthree("Image Inset Crop"),
44
+ };
45
+
46
+ /**
47
+ * Gets the list of nodes from NoteTypeString above, filtering any that are not applicable.
48
+ */
49
+ export function getNodeTypeStrings() {
50
+ return Object.values(NodeTypesString)
51
+ .map((i) => stripRgthree(i))
52
+ .filter((i) => {
53
+ if (
54
+ i.startsWith("Dynamic Context") &&
55
+ !CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")
56
+ ) {
57
+ return false;
58
+ }
59
+ return true;
60
+ })
61
+ .sort();
62
+ }
rgthree-comfy/src_web/comfyui/context.ts ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ INodeInputSlot,
3
+ INodeOutputSlot,
4
+ LGraphCanvas as TLGraphCanvas,
5
+ LGraphNode as TLGraphNode,
6
+ LLink,
7
+ } from "typings/litegraph.js";
8
+ import type { ComfyNodeConstructor, ComfyObjectInfo } from "typings/comfy.js";
9
+ import { app } from "scripts/app.js";
10
+ import {
11
+ IoDirection,
12
+ addConnectionLayoutSupport,
13
+ addMenuItem,
14
+ matchLocalSlotsToServer,
15
+ replaceNode,
16
+ } from "./utils.js";
17
+ import { RgthreeBaseServerNode } from "./base_node.js";
18
+ import { SERVICE as KEY_EVENT_SERVICE } from "./services/key_events_services.js";
19
+ import { RgthreeBaseServerNodeConstructor } from "typings/rgthree.js";
20
+ import { debounce, wait } from "rgthree/common/shared_utils.js";
21
+ import { removeUnusedInputsFromEnd } from "./utils_inputs_outputs.js";
22
+ import { NodeTypesString } from "./constants.js";
23
+
24
+ /**
25
+ * Takes a non-context node and determins for its input or output slot, if there is a valid
26
+ * connection for an opposite context output or input slot.
27
+ */
28
+ function findMatchingIndexByTypeOrName(
29
+ otherNode: TLGraphNode,
30
+ otherSlot: INodeInputSlot | INodeOutputSlot,
31
+ ctxSlots: INodeInputSlot[] | INodeOutputSlot[],
32
+ ) {
33
+ const otherNodeType = (otherNode.type || "").toUpperCase();
34
+ const otherNodeName = (otherNode.title || "").toUpperCase();
35
+ let otherSlotType = otherSlot.type as string;
36
+ if (Array.isArray(otherSlotType) || otherSlotType.includes(",")) {
37
+ otherSlotType = "COMBO";
38
+ }
39
+ const otherSlotName = otherSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
40
+ let ctxSlotIndex = -1;
41
+ if (["CONDITIONING", "INT", "STRING", "FLOAT", "COMBO"].includes(otherSlotType)) {
42
+ ctxSlotIndex = ctxSlots.findIndex((ctxSlot) => {
43
+ const ctxSlotName = ctxSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
44
+ let ctxSlotType = ctxSlot.type as string;
45
+ if (Array.isArray(ctxSlotType) || ctxSlotType.includes(",")) {
46
+ ctxSlotType = "COMBO";
47
+ }
48
+ if (ctxSlotType !== otherSlotType) {
49
+ return false;
50
+ }
51
+ // Straightforward matches.
52
+ if (
53
+ ctxSlotName === otherSlotName ||
54
+ (ctxSlotName === "SEED" && otherSlotName.includes("SEED")) ||
55
+ (ctxSlotName === "STEP_REFINER" && otherSlotName.includes("AT_STEP")) ||
56
+ (ctxSlotName === "STEP_REFINER" && otherSlotName.includes("REFINER_STEP"))
57
+ ) {
58
+ return true;
59
+ }
60
+ // If postive other node, try to match conditining and text.
61
+ if (
62
+ (otherNodeType.includes("POSITIVE") || otherNodeName.includes("POSITIVE")) &&
63
+ ((ctxSlotName === "POSITIVE" && otherSlotType === "CONDITIONING") ||
64
+ (ctxSlotName === "TEXT_POS_G" && otherSlotName.includes("TEXT_G")) ||
65
+ (ctxSlotName === "TEXT_POS_L" && otherSlotName.includes("TEXT_L")))
66
+ ) {
67
+ return true;
68
+ }
69
+ if (
70
+ (otherNodeType.includes("NEGATIVE") || otherNodeName.includes("NEGATIVE")) &&
71
+ ((ctxSlotName === "NEGATIVE" && otherSlotType === "CONDITIONING") ||
72
+ (ctxSlotName === "TEXT_NEG_G" && otherSlotName.includes("TEXT_G")) ||
73
+ (ctxSlotName === "TEXT_NEG_L" && otherSlotName.includes("TEXT_L")))
74
+ ) {
75
+ return true;
76
+ }
77
+ return false;
78
+ });
79
+ } else {
80
+ ctxSlotIndex = ctxSlots.map((s) => s.type).indexOf(otherSlotType);
81
+ }
82
+ return ctxSlotIndex;
83
+ }
84
+
85
+ /**
86
+ * A Base Context node for other context based nodes to extend.
87
+ */
88
+ export class BaseContextNode extends RgthreeBaseServerNode {
89
+ constructor(title: string) {
90
+ super(title);
91
+ }
92
+
93
+ // LiteGraph adds more spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
94
+ // override it with a setter and re-set it measured exactly as we want.
95
+ ___collapsed_width: number = 0;
96
+
97
+ //@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
98
+ override get _collapsed_width() {
99
+ return this.___collapsed_width;
100
+ }
101
+
102
+ override set _collapsed_width(width: number) {
103
+ const canvas = app.canvas as TLGraphCanvas;
104
+ const ctx = canvas.canvas.getContext("2d")!;
105
+ const oldFont = ctx.font;
106
+ ctx.font = canvas.title_text_font;
107
+ let title = this.title.trim();
108
+ this.___collapsed_width = 30 + (title ? 10 + ctx.measureText(title).width : 0);
109
+ ctx.font = oldFont;
110
+ }
111
+
112
+ override connectByType<T = any>(
113
+ slot: string | number,
114
+ sourceNode: TLGraphNode,
115
+ sourceSlotType: string,
116
+ optsIn: string,
117
+ ): T | null {
118
+ let canConnect =
119
+ super.connectByType &&
120
+ super.connectByType.call(this, slot, sourceNode, sourceSlotType, optsIn);
121
+ if (!super.connectByType) {
122
+ canConnect = LGraphNode.prototype.connectByType.call(
123
+ this,
124
+ slot,
125
+ sourceNode,
126
+ sourceSlotType,
127
+ optsIn,
128
+ );
129
+ }
130
+ if (!canConnect && slot === 0) {
131
+ const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
132
+ // Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
133
+ // Unfortunately, we don't know which are null now, so we'll just connect any that are
134
+ // not already connected.
135
+ for (const [index, input] of (sourceNode.inputs || []).entries()) {
136
+ if (input.link && !ctrlKey) {
137
+ continue;
138
+ }
139
+ const thisOutputSlot = findMatchingIndexByTypeOrName(sourceNode, input, this.outputs);
140
+ if (thisOutputSlot > -1) {
141
+ this.connect(thisOutputSlot, sourceNode, index);
142
+ }
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ override connectByTypeOutput<T = any>(
149
+ slot: string | number,
150
+ sourceNode: TLGraphNode,
151
+ sourceSlotType: string,
152
+ optsIn: string,
153
+ ): T | null {
154
+ let canConnect =
155
+ super.connectByTypeOutput &&
156
+ super.connectByTypeOutput.call(this, slot, sourceNode, sourceSlotType, optsIn);
157
+ if (!super.connectByType) {
158
+ canConnect = LGraphNode.prototype.connectByTypeOutput.call(
159
+ this,
160
+ slot,
161
+ sourceNode,
162
+ sourceSlotType,
163
+ optsIn,
164
+ );
165
+ }
166
+ if (!canConnect && slot === 0) {
167
+ const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
168
+ // Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
169
+ // Unfortunately, we don't know which are null now, so we'll just connect any that are
170
+ // not already connected.
171
+ for (const [index, output] of (sourceNode.outputs || []).entries()) {
172
+ if (output.links?.length && !ctrlKey) {
173
+ continue;
174
+ }
175
+ const thisInputSlot = findMatchingIndexByTypeOrName(sourceNode, output, this.inputs);
176
+ if (thisInputSlot > -1) {
177
+ sourceNode.connect(index, this, thisInputSlot);
178
+ }
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ static override setUp(
185
+ comfyClass: ComfyNodeConstructor,
186
+ nodeData: ComfyObjectInfo,
187
+ ctxClass: RgthreeBaseServerNodeConstructor,
188
+ ) {
189
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ctxClass);
190
+ // [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
191
+ // empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
192
+ // ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
193
+ // because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
194
+ // extension and we need to wait for that to happen.
195
+ wait(500).then(() => {
196
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] =
197
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] || [];
198
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"].push(comfyClass.comfyClass);
199
+ });
200
+ }
201
+
202
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
203
+ addConnectionLayoutSupport(ctxClass, app, [
204
+ ["Left", "Right"],
205
+ ["Right", "Left"],
206
+ ]);
207
+ setTimeout(() => {
208
+ ctxClass.category = comfyClass.category;
209
+ });
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The original Context node.
215
+ */
216
+ class ContextNode extends BaseContextNode {
217
+ static override title = NodeTypesString.CONTEXT;
218
+ static override type = NodeTypesString.CONTEXT;
219
+ static comfyClass = NodeTypesString.CONTEXT;
220
+
221
+ constructor(title = ContextNode.title) {
222
+ super(title);
223
+ }
224
+
225
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
226
+ BaseContextNode.setUp(comfyClass, nodeData, ContextNode);
227
+ }
228
+
229
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
230
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
231
+ addMenuItem(ContextNode, app, {
232
+ name: "Convert To Context Big",
233
+ callback: (node) => {
234
+ replaceNode(node, ContextBigNode.type);
235
+ },
236
+ });
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The Context Big node.
242
+ */
243
+ class ContextBigNode extends BaseContextNode {
244
+ static override title = NodeTypesString.CONTEXT_BIG;
245
+ static override type = NodeTypesString.CONTEXT_BIG;
246
+ static comfyClass = NodeTypesString.CONTEXT_BIG;
247
+
248
+ constructor(title = ContextBigNode.title) {
249
+ super(title);
250
+ }
251
+
252
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
253
+ BaseContextNode.setUp(comfyClass, nodeData, ContextBigNode);
254
+ }
255
+
256
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
257
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
258
+ addMenuItem(ContextBigNode, app, {
259
+ name: "Convert To Context (Original)",
260
+ callback: (node) => {
261
+ replaceNode(node, ContextNode.type);
262
+ },
263
+ });
264
+ }
265
+ }
266
+
267
+ /**
268
+ * A base node for Context Switche nodes and Context Merges nodes that will always add another empty
269
+ * ctx input, no less than five.
270
+ */
271
+ class BaseContextMultiCtxInputNode extends BaseContextNode {
272
+ private stabilizeBound = this.stabilize.bind(this);
273
+
274
+ constructor(title: string) {
275
+ super(title);
276
+ // Adding five. Note, configure will add as many as was in the stored workflow automatically.
277
+ this.addContextInput(5);
278
+ }
279
+
280
+ private addContextInput(num = 1) {
281
+ for (let i = 0; i < num; i++) {
282
+ this.addInput(`ctx_${String(this.inputs.length + 1).padStart(2, "0")}`, "RGTHREE_CONTEXT");
283
+ }
284
+ }
285
+
286
+ override onConnectionsChange(
287
+ type: number,
288
+ slotIndex: number,
289
+ isConnected: boolean,
290
+ link: LLink,
291
+ ioSlot: INodeInputSlot | INodeOutputSlot,
292
+ ): void {
293
+ super.onConnectionsChange?.apply(this, [...arguments] as any);
294
+ if (type === LiteGraph.INPUT) {
295
+ this.scheduleStabilize();
296
+ }
297
+ }
298
+
299
+ private scheduleStabilize(ms = 64) {
300
+ return debounce(this.stabilizeBound, 64);
301
+ }
302
+
303
+ /**
304
+ * Stabilizes the inputs; removing any disconnected ones from the bottom, then adding an empty
305
+ * one to the end so we always have one empty one to expand.
306
+ */
307
+ private stabilize() {
308
+ removeUnusedInputsFromEnd(this, 4);
309
+ this.addContextInput();
310
+ }
311
+ }
312
+
313
+ /**
314
+ * The Context Switch (original) node.
315
+ */
316
+ class ContextSwitchNode extends BaseContextMultiCtxInputNode {
317
+ static override title = NodeTypesString.CONTEXT_SWITCH;
318
+ static override type = NodeTypesString.CONTEXT_SWITCH;
319
+ static comfyClass = NodeTypesString.CONTEXT_SWITCH;
320
+
321
+ constructor(title = ContextSwitchNode.title) {
322
+ super(title);
323
+ }
324
+
325
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
326
+ BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchNode);
327
+ }
328
+
329
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
330
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
331
+ addMenuItem(ContextSwitchNode, app, {
332
+ name: "Convert To Context Switch Big",
333
+ callback: (node) => {
334
+ replaceNode(node, ContextSwitchBigNode.type);
335
+ },
336
+ });
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The Context Switch Big node.
342
+ */
343
+ class ContextSwitchBigNode extends BaseContextMultiCtxInputNode {
344
+ static override title = NodeTypesString.CONTEXT_SWITCH_BIG;
345
+ static override type = NodeTypesString.CONTEXT_SWITCH_BIG;
346
+ static comfyClass = NodeTypesString.CONTEXT_SWITCH_BIG;
347
+
348
+ constructor(title = ContextSwitchBigNode.title) {
349
+ super(title);
350
+ }
351
+
352
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
353
+ BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchBigNode);
354
+ }
355
+
356
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
357
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
358
+ addMenuItem(ContextSwitchBigNode, app, {
359
+ name: "Convert To Context Switch",
360
+ callback: (node) => {
361
+ replaceNode(node, ContextSwitchNode.type);
362
+ },
363
+ });
364
+ }
365
+ }
366
+
367
+ /**
368
+ * The Context Merge (original) node.
369
+ */
370
+ class ContextMergeNode extends BaseContextMultiCtxInputNode {
371
+ static override title = NodeTypesString.CONTEXT_MERGE;
372
+ static override type = NodeTypesString.CONTEXT_MERGE;
373
+ static comfyClass = NodeTypesString.CONTEXT_MERGE;
374
+
375
+ constructor(title = ContextMergeNode.title) {
376
+ super(title);
377
+ }
378
+
379
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
380
+ BaseContextNode.setUp(comfyClass, nodeData, ContextMergeNode);
381
+ }
382
+
383
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
384
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
385
+ addMenuItem(ContextMergeNode, app, {
386
+ name: "Convert To Context Merge Big",
387
+ callback: (node) => {
388
+ replaceNode(node, ContextMergeBigNode.type);
389
+ },
390
+ });
391
+ }
392
+ }
393
+
394
+ /**
395
+ * The Context Switch Big node.
396
+ */
397
+ class ContextMergeBigNode extends BaseContextMultiCtxInputNode {
398
+ static override title = NodeTypesString.CONTEXT_MERGE_BIG;
399
+ static override type = NodeTypesString.CONTEXT_MERGE_BIG;
400
+ static comfyClass = NodeTypesString.CONTEXT_MERGE_BIG;
401
+
402
+ constructor(title = ContextMergeBigNode.title) {
403
+ super(title);
404
+ }
405
+
406
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
407
+ BaseContextNode.setUp(comfyClass, nodeData, ContextMergeBigNode);
408
+ }
409
+
410
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
411
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
412
+ addMenuItem(ContextMergeBigNode, app, {
413
+ name: "Convert To Context Switch",
414
+ callback: (node) => {
415
+ replaceNode(node, ContextMergeNode.type);
416
+ },
417
+ });
418
+ }
419
+ }
420
+
421
+ const contextNodes = [
422
+ ContextNode,
423
+ ContextBigNode,
424
+ ContextSwitchNode,
425
+ ContextSwitchBigNode,
426
+ ContextMergeNode,
427
+ ContextMergeBigNode,
428
+ ];
429
+ const contextTypeToServerDef: { [type: string]: ComfyObjectInfo } = {};
430
+
431
+ function fixBadConfigs(node: ContextNode) {
432
+ // Dumb mistake, but let's fix our mispelling. This will probably need to stay in perpetuity to
433
+ // keep any old workflows operating.
434
+ const wrongName = node.outputs.find((o, i) => o.name === "CLIP_HEIGTH");
435
+ if (wrongName) {
436
+ wrongName.name = "CLIP_HEIGHT";
437
+ }
438
+ }
439
+
440
+ app.registerExtension({
441
+ name: "rgthree.Context",
442
+ async beforeRegisterNodeDef(nodeType: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
443
+ // Loop over out context nodes and see if any match the server data.
444
+ for (const ctxClass of contextNodes) {
445
+ if (nodeData.name === ctxClass.type) {
446
+ contextTypeToServerDef[ctxClass.type] = nodeData;
447
+ ctxClass.setUp(nodeType, nodeData);
448
+ break;
449
+ }
450
+ }
451
+ },
452
+
453
+ async nodeCreated(node: TLGraphNode) {
454
+ const type = node.type || (node.constructor as any).type;
455
+ const serverDef = type && contextTypeToServerDef[type];
456
+ if (serverDef) {
457
+ fixBadConfigs(node as ContextNode);
458
+ matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
459
+ // Switches don't need to change inputs, only context outputs
460
+ if (!type!.includes("Switch") && !type!.includes("Merge")) {
461
+ matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
462
+ }
463
+ // }, 100);
464
+ }
465
+ },
466
+
467
+ /**
468
+ * When we're loaded from the server, check if we're using an out of date version and update our
469
+ * inputs / outputs to match.
470
+ */
471
+ async loadedGraphNode(node: TLGraphNode) {
472
+ const type = node.type || (node.constructor as any).type;
473
+ const serverDef = type && contextTypeToServerDef[type];
474
+ if (serverDef) {
475
+ fixBadConfigs(node as ContextNode);
476
+ matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
477
+ // Switches don't need to change inputs, only context outputs
478
+ if (!type!.includes("Switch") && !type!.includes("Merge")) {
479
+ matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
480
+ }
481
+ }
482
+ },
483
+ });
rgthree-comfy/src_web/comfyui/dialog_info.ts ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {RgthreeDialog, RgthreeDialogOptions} from "rgthree/common/dialog.js";
2
+ import {
3
+ createElement as $el,
4
+ empty,
5
+ appendChildren,
6
+ getClosestOrSelf,
7
+ queryOne,
8
+ query,
9
+ setAttributes,
10
+ } from "rgthree/common/utils_dom.js";
11
+ import {
12
+ logoCivitai,
13
+ link,
14
+ pencilColored,
15
+ diskColored,
16
+ dotdotdot,
17
+ } from "rgthree/common/media/svgs.js";
18
+ import {RgthreeModelInfo} from "typings/rgthree.js";
19
+ import {LORA_INFO_SERVICE} from "rgthree/common/model_info_service.js";
20
+ import {rgthree} from "./rgthree.js";
21
+ import {MenuButton} from "rgthree/common/menu.js";
22
+ import {generateId, injectCss} from "rgthree/common/shared_utils.js";
23
+ import {rgthreeApi} from "rgthree/common/rgthree_api.js";
24
+
25
+ /**
26
+ * A dialog that displays information about a model/lora/etc.
27
+ */
28
+ abstract class RgthreeInfoDialog extends RgthreeDialog {
29
+ private modifiedModelData = false;
30
+ private modelInfo: RgthreeModelInfo | null = null;
31
+
32
+ constructor(file: string, type: string = "lora") {
33
+ const dialogOptions: RgthreeDialogOptions = {
34
+ class: "rgthree-info-dialog",
35
+ title: `<h2>Loading...</h2>`,
36
+ content: "<center>Loading..</center>",
37
+ onBeforeClose: () => {
38
+ return true;
39
+ },
40
+ };
41
+ super(dialogOptions);
42
+ this.init(file);
43
+ }
44
+
45
+ abstract getModelInfo(file: string): Promise<RgthreeModelInfo | null>;
46
+ abstract refreshModelInfo(file: string): Promise<RgthreeModelInfo | null>;
47
+ abstract clearModelInfo(file: string): Promise<RgthreeModelInfo | null>;
48
+
49
+ private async init(file: string) {
50
+ const cssPromise = injectCss("rgthree/common/css/dialog_model_info.css");
51
+ this.modelInfo = await this.getModelInfo(file);
52
+ await cssPromise;
53
+ this.setContent(this.getInfoContent());
54
+ this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
55
+ this.attachEvents();
56
+ }
57
+
58
+ protected override getCloseEventDetail(): {detail: any} {
59
+ const detail = {
60
+ dirty: this.modifiedModelData,
61
+ };
62
+ return {detail};
63
+ }
64
+
65
+ private attachEvents() {
66
+ this.contentElement.addEventListener("click", async (e: MouseEvent) => {
67
+ const target = getClosestOrSelf(e.target as HTMLElement, "[data-action]");
68
+ const action = target?.getAttribute("data-action");
69
+ if (!target || !action) {
70
+ return;
71
+ }
72
+ await this.handleEventAction(action, target, e);
73
+ });
74
+ }
75
+
76
+ private async handleEventAction(action: string, target: HTMLElement, e?: Event) {
77
+ const info = this.modelInfo!;
78
+ if (!info?.file) {
79
+ return;
80
+ }
81
+ if (action === "fetch-civitai") {
82
+ this.modelInfo = await this.refreshModelInfo(info.file);
83
+ this.setContent(this.getInfoContent());
84
+ this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
85
+ } else if (action === "copy-trained-words") {
86
+ const selected = query(".-rgthree-is-selected", target.closest("tr")!);
87
+ const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
88
+ await navigator.clipboard.writeText(text);
89
+ rgthree.showMessage({
90
+ id: "copy-trained-words-" + generateId(4),
91
+ type: "success",
92
+ message: `Successfully copied ${selected.length} key word${
93
+ selected.length === 1 ? "" : "s"
94
+ }.`,
95
+ timeout: 4000,
96
+ });
97
+ } else if (action === "toggle-trained-word") {
98
+ target?.classList.toggle("-rgthree-is-selected");
99
+ const tr = target.closest("tr");
100
+ if (tr) {
101
+ const span = queryOne("td:first-child > *", tr)!;
102
+ let small = queryOne("small", span);
103
+ if (!small) {
104
+ small = $el("small", {parent: span});
105
+ }
106
+ const num = query(".-rgthree-is-selected", tr).length;
107
+ small.innerHTML = num
108
+ ? `${num} selected | <span role="button" data-action="copy-trained-words">Copy</span>`
109
+ : "";
110
+ // this.handleEventAction('copy-trained-words', target, e);
111
+ }
112
+ } else if (action === "edit-row") {
113
+ const tr = target!.closest("tr")!;
114
+ const td = queryOne("td:nth-child(2)", tr)!;
115
+ const input = td.querySelector("input,textarea");
116
+ if (!input) {
117
+ const fieldName = tr.dataset["fieldName"] as string;
118
+ tr.classList.add("-rgthree-editing");
119
+ const isTextarea = fieldName === "userNote";
120
+ const input = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, {
121
+ value: td.textContent,
122
+ });
123
+ input.addEventListener("keydown", (e) => {
124
+ if (!isTextarea && e.key === "Enter") {
125
+ const modified = saveEditableRow(info!, tr, true);
126
+ this.modifiedModelData = this.modifiedModelData || modified;
127
+ e.stopPropagation();
128
+ e.preventDefault();
129
+ } else if (e.key === "Escape") {
130
+ const modified = saveEditableRow(info!, tr, false);
131
+ this.modifiedModelData = this.modifiedModelData || modified;
132
+ e.stopPropagation();
133
+ e.preventDefault();
134
+ }
135
+ });
136
+ appendChildren(empty(td), [input]);
137
+ input.focus();
138
+ } else if (target!.nodeName.toLowerCase() === "button") {
139
+ const modified = saveEditableRow(info!, tr, true);
140
+ this.modifiedModelData = this.modifiedModelData || modified;
141
+ }
142
+ e?.preventDefault();
143
+ e?.stopPropagation();
144
+ }
145
+ }
146
+
147
+ private getInfoContent() {
148
+ const info = this.modelInfo || {};
149
+ const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
150
+ const html = `
151
+ <ul class="rgthree-info-area">
152
+ <li title="Type" class="rgthree-info-tag -type -type-${(
153
+ info.type || ""
154
+ ).toLowerCase()}"><span>${info.type || ""}</span></li>
155
+ <li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(
156
+ info.baseModel || ""
157
+ ).toLowerCase()}"><span>${info.baseModel || ""}</span></li>
158
+ <li class="rgthree-info-menu" stub="menu"></li>
159
+ ${
160
+ ""
161
+ // !civitaiLink
162
+ // ? ""
163
+ // : `
164
+ // <li title="Visit on Civitai" class="-link -civitai"><a href="${civitaiLink}" target="_blank">Civitai ${link}</a></li>
165
+ // `
166
+ }
167
+ </ul>
168
+
169
+ <table class="rgthree-info-table">
170
+ ${infoTableRow("File", info.file || "")}
171
+ ${infoTableRow("Hash (sha256)", info.sha256 || "")}
172
+ ${
173
+ civitaiLink
174
+ ? infoTableRow(
175
+ "Civitai",
176
+ `<a href="${civitaiLink}" target="_blank">${logoCivitai}View on Civitai</a>`,
177
+ )
178
+ : info.raw?.civitai?.error === "Model not found"
179
+ ? infoTableRow(
180
+ "Civitai",
181
+ '<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>',
182
+ )
183
+ : info.raw?.civitai?.error
184
+ ? infoTableRow("Civitai", info.raw?.civitai?.error)
185
+ : !info.raw?.civitai
186
+ ? infoTableRow(
187
+ "Civitai",
188
+ `<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`,
189
+ )
190
+ : ""
191
+ }
192
+
193
+ ${infoTableRow(
194
+ "Name",
195
+ info.name || info.raw?.metadata?.ss_output_name || "",
196
+ "The name for display.",
197
+ "name",
198
+ )}
199
+
200
+ ${
201
+ !info.baseModelFile && !info.baseModelFile
202
+ ? ""
203
+ : infoTableRow(
204
+ "Base Model",
205
+ (info.baseModel || "") + (info.baseModelFile ? ` (${info.baseModelFile})` : ""),
206
+ )
207
+ }
208
+
209
+
210
+ ${
211
+ !info.trainedWords?.length
212
+ ? ""
213
+ : infoTableRow(
214
+ "Trained Words",
215
+ getTrainedWordsMarkup(info.trainedWords) ?? "",
216
+ "Trained words from the metadata and/or civitai. Click to select for copy.",
217
+ )
218
+ }
219
+
220
+ ${
221
+ !info.raw?.metadata?.ss_clip_skip || info.raw?.metadata?.ss_clip_skip == "None"
222
+ ? ""
223
+ : infoTableRow("Clip Skip", info.raw?.metadata?.ss_clip_skip)
224
+ }
225
+ ${infoTableRow(
226
+ "Strength Min",
227
+ info.strengthMin ?? "",
228
+ "The recommended minimum strength, In the Power Lora Loader node, strength will signal when it is below this threshold.",
229
+ "strengthMin",
230
+ )}
231
+ ${infoTableRow(
232
+ "Strength Max",
233
+ info.strengthMax ?? "",
234
+ "The recommended maximum strength. In the Power Lora Loader node, strength will signal when it is above this threshold.",
235
+ "strengthMax",
236
+ )}
237
+ ${
238
+ "" /*infoTableRow(
239
+ "User Tags",
240
+ info.userTags?.join(", ") ?? "",
241
+ "A list of tags to make filtering easier in the Power Lora Chooser.",
242
+ "userTags",
243
+ )*/
244
+ }
245
+ ${infoTableRow(
246
+ "Additional Notes",
247
+ info.userNote ?? "",
248
+ "Additional notes you'd like to keep and reference in the info dialog.",
249
+ "userNote",
250
+ )}
251
+
252
+ </table>
253
+
254
+ <ul class="rgthree-info-images">${
255
+ info.images
256
+ ?.map(
257
+ (img) => `
258
+ <li>
259
+ <figure>
260
+ <img src="${img.url}" />
261
+ <figcaption><!--
262
+ -->${imgInfoField(
263
+ "",
264
+ img.civitaiUrl
265
+ ? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a>`
266
+ : undefined,
267
+ )}<!--
268
+ -->${imgInfoField("seed", img.seed)}<!--
269
+ -->${imgInfoField("steps", img.steps)}<!--
270
+ -->${imgInfoField("cfg", img.cfg)}<!--
271
+ -->${imgInfoField("sampler", img.sampler)}<!--
272
+ -->${imgInfoField("model", img.model)}<!--
273
+ -->${imgInfoField("positive", img.positive)}<!--
274
+ -->${imgInfoField("negative", img.negative)}<!--
275
+ --><!--${
276
+ ""
277
+ // img.resources?.length
278
+ // ? `
279
+ // <tr><td>Resources</td><td><ul>
280
+ // ${(img.resources || [])
281
+ // .map(
282
+ // (r) => `
283
+ // <li>[${r.type || ""}] ${r.name || ""} ${
284
+ // r.weight != null ? `@ ${r.weight}` : ""
285
+ // }</li>
286
+ // `,
287
+ // )
288
+ // .join("")}
289
+ // </ul></td></tr>
290
+ // `
291
+ // : ""
292
+ }--></figcaption>
293
+ </figure>
294
+ </li>`,
295
+ )
296
+ .join("") ?? ""
297
+ }</ul>
298
+ `;
299
+
300
+ const div = $el("div", {html});
301
+
302
+ if (rgthree.isDevMode()) {
303
+ setAttributes(queryOne('[stub="menu"]', div)!, {
304
+ children: [
305
+ new MenuButton({
306
+ icon: dotdotdot,
307
+ options: [
308
+ {label: "More Actions", type: "title"},
309
+ {
310
+ label: "Open API JSON",
311
+ callback: async (e: PointerEvent) => {
312
+ if (this.modelInfo?.file) {
313
+ window.open(
314
+ `rgthree/api/loras/info?file=${encodeURIComponent(this.modelInfo.file)}`,
315
+ );
316
+ }
317
+ },
318
+ },
319
+ {
320
+ label: "Clear all local info",
321
+ callback: async (e: PointerEvent) => {
322
+ if (this.modelInfo?.file) {
323
+ this.modelInfo = await LORA_INFO_SERVICE.clearFetchedInfo(this.modelInfo.file);
324
+ this.setContent(this.getInfoContent());
325
+ this.setTitle(
326
+ this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown",
327
+ );
328
+ }
329
+ },
330
+ },
331
+ ],
332
+ }),
333
+ ],
334
+ });
335
+ }
336
+
337
+ return div;
338
+ }
339
+ }
340
+
341
+ export class RgthreeLoraInfoDialog extends RgthreeInfoDialog {
342
+ override async getModelInfo(file: string) {
343
+ return LORA_INFO_SERVICE.getInfo(file, false, false);
344
+ }
345
+ override async refreshModelInfo(file: string) {
346
+ return LORA_INFO_SERVICE.refreshInfo(file);
347
+ }
348
+ override async clearModelInfo(file: string) {
349
+ return LORA_INFO_SERVICE.clearFetchedInfo(file);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Generates a uniform markup string for a table row.
355
+ */
356
+ function infoTableRow(
357
+ name: string,
358
+ value: string | number,
359
+ help: string = "",
360
+ editableFieldName = "",
361
+ ) {
362
+ return `
363
+ <tr class="${editableFieldName ? "editable" : ""}" ${
364
+ editableFieldName ? `data-field-name="${editableFieldName}"` : ""
365
+ }>
366
+ <td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
367
+ <td ${editableFieldName ? "" : 'colspan="2"'}>${
368
+ String(value).startsWith("<") ? value : `<span>${value}<span>`
369
+ }</td>
370
+ ${
371
+ editableFieldName
372
+ ? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>`
373
+ : ""
374
+ }
375
+ </tr>`;
376
+ }
377
+
378
+ function getTrainedWordsMarkup(words: RgthreeModelInfo["trainedWords"]) {
379
+ let markup = `<ul class="rgthree-info-trained-words-list">`;
380
+ for (const wordData of words || []) {
381
+ markup += `<li title="${wordData.word}" data-word="${
382
+ wordData.word
383
+ }" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
384
+ <span>${wordData.word}</span>
385
+ ${wordData.civitai ? logoCivitai : ""}
386
+ ${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
387
+ </li>`;
388
+ }
389
+ markup += `</ul>`;
390
+ return markup;
391
+ }
392
+
393
+ /**
394
+ * Saves / cancels an editable row. Returns a boolean if the data was modified.
395
+ */
396
+ function saveEditableRow(info: RgthreeModelInfo, tr: HTMLElement, saving = true): boolean {
397
+ const fieldName = tr.dataset["fieldName"] as "file";
398
+ const input = queryOne<HTMLInputElement>("input,textarea", tr)!;
399
+ let newValue = info[fieldName] ?? "";
400
+ let modified = false;
401
+ if (saving) {
402
+ newValue = input!.value;
403
+ if (fieldName.startsWith("strength")) {
404
+ if (Number.isNaN(Number(newValue))) {
405
+ alert(`You must enter a number into the ${fieldName} field.`);
406
+ return false;
407
+ }
408
+ newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
409
+ }
410
+ LORA_INFO_SERVICE.savePartialInfo(info.file!, {[fieldName]: newValue});
411
+ modified = true;
412
+ }
413
+ tr.classList.remove("-rgthree-editing");
414
+ const td = queryOne("td:nth-child(2)", tr)!;
415
+ appendChildren(empty(td), [$el("span", {text: newValue})]);
416
+ return modified;
417
+ }
418
+
419
+ function imgInfoField(label: string, value?: string | number) {
420
+ return value != null ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
421
+ }
rgthree-comfy/src_web/comfyui/display_any.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { ComfyWidgets } from "scripts/widgets.js";
3
+ import type { LGraphNode as TLGraphNode } from "typings/litegraph.js";
4
+ import type { ComfyApp, ComfyNodeConstructor, ComfyObjectInfo } from "typings/comfy.js";
5
+ import { addConnectionLayoutSupport } from "./utils.js";
6
+ import { rgthree } from "./rgthree.js";
7
+
8
+ let hasShownAlertForUpdatingInt = false;
9
+
10
+ app.registerExtension({
11
+ name: "rgthree.DisplayAny",
12
+ async beforeRegisterNodeDef(
13
+ nodeType: ComfyNodeConstructor,
14
+ nodeData: ComfyObjectInfo,
15
+ app: ComfyApp,
16
+ ) {
17
+ if (nodeData.name === "Display Any (rgthree)" || nodeData.name === "Display Int (rgthree)") {
18
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
19
+ nodeType.prototype.onNodeCreated = function () {
20
+ onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
21
+
22
+ (this as any).showValueWidget = ComfyWidgets["STRING"](
23
+ this,
24
+ "output",
25
+ ["STRING", { multiline: true }],
26
+ app,
27
+ ).widget;
28
+ (this as any).showValueWidget.inputEl!.readOnly = true;
29
+ (this as any).showValueWidget.serializeValue = async (node: TLGraphNode, index: number) => {
30
+ const n =
31
+ rgthree.getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff(node);
32
+ if (n) {
33
+ // Since we need a round trip to get the value, the serizalized value means nothing, and
34
+ // saving it to the metadata would just be confusing. So, we clear it here.
35
+ n.widgets_values![index] = "";
36
+ } else {
37
+ console.warn(
38
+ "No serialized node found in workflow. May be attributed to " +
39
+ "https://github.com/comfyanonymous/ComfyUI/issues/2193",
40
+ );
41
+ }
42
+ return "";
43
+ };
44
+ };
45
+
46
+ addConnectionLayoutSupport(nodeType, app, [["Left"], ["Right"]]);
47
+
48
+ const onExecuted = nodeType.prototype.onExecuted;
49
+ nodeType.prototype.onExecuted = function (message: any) {
50
+ onExecuted?.apply(this, [message]);
51
+ (this as any).showValueWidget.value = message.text[0];
52
+ };
53
+ }
54
+ },
55
+
56
+ // This ports Display Int to DisplayAny, but ComfyUI still shows an error.
57
+ // If https://github.com/comfyanonymous/ComfyUI/issues/1527 is fixed, this could work.
58
+ // async loadedGraphNode(node: TLGraphNode) {
59
+ // if (node.type === "Display Int (rgthree)") {
60
+ // replaceNode(node, "Display Any (rgthree)", new Map([["input", "source"]]));
61
+ // if (!hasShownAlertForUpdatingInt) {
62
+ // hasShownAlertForUpdatingInt = true;
63
+ // setTimeout(() => {
64
+ // alert(
65
+ // "Don't worry, your 'Display Int' nodes have been updated to the new " +
66
+ // "'Display Any' nodes! You can ignore the error message underneath (for that node)." +
67
+ // "\n\nThanks.\n- rgthree",
68
+ // );
69
+ // }, 128);
70
+ // }
71
+ // }
72
+ // },
73
+ });
rgthree-comfy/src_web/comfyui/dynamic_context.ts ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {app} from "scripts/app.js";
2
+ import {
3
+ IoDirection,
4
+ followConnectionUntilType,
5
+ getConnectedInputInfosAndFilterPassThroughs,
6
+ } from "./utils.js";
7
+ import {rgthree} from "./rgthree.js";
8
+ import {
9
+ SERVICE as CONTEXT_SERVICE,
10
+ InputMutation,
11
+ InputMutationOperation,
12
+ } from "./services/context_service.js";
13
+ import {NodeTypesString} from "./constants.js";
14
+ import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
15
+ import {INodeInputSlot, INodeOutputSlot, INodeSlot, LGraphNode, LLink} from "typings/litegraph.js";
16
+ import {ComfyNodeConstructor, ComfyObjectInfo} from "typings/comfy.js";
17
+ import {DynamicContextNodeBase} from "./dynamic_context_base.js";
18
+ import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
19
+
20
+ const OWNED_PREFIX = "+";
21
+ const REGEX_OWNED_PREFIX = /^\+\s*/;
22
+ const REGEX_EMPTY_INPUT = /^\+\s*$/;
23
+
24
+ /**
25
+ * The Dynamic Context node.
26
+ */
27
+ export class DynamicContextNode extends DynamicContextNodeBase {
28
+ static override title = NodeTypesString.DYNAMIC_CONTEXT;
29
+ static override type = NodeTypesString.DYNAMIC_CONTEXT;
30
+ static comfyClass = NodeTypesString.DYNAMIC_CONTEXT;
31
+
32
+ constructor(title = DynamicContextNode.title) {
33
+ super(title);
34
+ }
35
+
36
+ override onNodeCreated() {
37
+ this.addInput("base_ctx", "RGTHREE_DYNAMIC_CONTEXT");
38
+ this.ensureOneRemainingNewInputSlot();
39
+ super.onNodeCreated();
40
+ }
41
+
42
+ override onConnectionsChange(
43
+ type: number,
44
+ slotIndex: number,
45
+ isConnected: boolean,
46
+ link: LLink,
47
+ ioSlot: INodeSlot,
48
+ ): void {
49
+ super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, ioSlot);
50
+ if (this.configuring) {
51
+ return;
52
+ }
53
+ if (type === LiteGraph.INPUT) {
54
+ if (isConnected) {
55
+ this.handleInputConnected(slotIndex);
56
+ } else {
57
+ this.handleInputDisconnected(slotIndex);
58
+ }
59
+ }
60
+ }
61
+
62
+ override onConnectInput(
63
+ inputIndex: number,
64
+ outputType: INodeOutputSlot["type"],
65
+ outputSlot: INodeOutputSlot,
66
+ outputNode: LGraphNode,
67
+ outputIndex: number,
68
+ ): boolean {
69
+ let canConnect = true;
70
+ if (super.onConnectInput) {
71
+ canConnect = super.onConnectInput.apply(this, [...arguments] as any);
72
+ }
73
+ if (
74
+ canConnect &&
75
+ outputNode instanceof DynamicContextNode &&
76
+ outputIndex === 0 &&
77
+ inputIndex !== 0
78
+ ) {
79
+ const [n, v] = rgthree.logger.warnParts(
80
+ "Currently, you can only connect a context node in the first slot.",
81
+ );
82
+ console[n]?.call(console, ...v);
83
+ canConnect = false;
84
+ }
85
+ return canConnect;
86
+ }
87
+
88
+ handleInputConnected(slotIndex: number) {
89
+ const ioSlot = this.inputs[slotIndex];
90
+ const connectedIndexes = [];
91
+ if (slotIndex === 0) {
92
+ let baseNodeInfos = getConnectedInputInfosAndFilterPassThroughs(this, this, 0);
93
+ const baseNodes = baseNodeInfos.map((n) => n.node)!;
94
+ const baseNodesDynamicCtx = baseNodes[0] as DynamicContextNodeBase;
95
+ if (baseNodesDynamicCtx?.provideInputsData) {
96
+ const inputsData = CONTEXT_SERVICE.getDynamicContextInputsData(baseNodesDynamicCtx);
97
+ console.log("inputsData", inputsData);
98
+ for (const input of baseNodesDynamicCtx.provideInputsData()) {
99
+ if (input.name === "base_ctx" || input.name === "+") {
100
+ continue;
101
+ }
102
+ this.addContextInput(input.name, input.type, input.index);
103
+ this.stabilizeNames();
104
+ }
105
+ }
106
+ } else if (this.isInputSlotForNewInput(slotIndex)) {
107
+ this.handleNewInputConnected(slotIndex);
108
+ }
109
+ }
110
+
111
+ isInputSlotForNewInput(slotIndex: number) {
112
+ const ioSlot = this.inputs[slotIndex];
113
+ return ioSlot && ioSlot.name === "+" && ioSlot.type === "*";
114
+ }
115
+
116
+ handleNewInputConnected(slotIndex: number) {
117
+ if (!this.isInputSlotForNewInput(slotIndex)) {
118
+ throw new Error('Expected the incoming slot index to be the "new input" input.');
119
+ }
120
+ const ioSlot = this.inputs[slotIndex]!;
121
+ let cxn = null;
122
+ if (ioSlot.link != null) {
123
+ cxn = followConnectionUntilType(this, IoDirection.INPUT, slotIndex, true);
124
+ }
125
+ if (cxn?.type && cxn?.name) {
126
+ let name = this.addOwnedPrefix(this.getNextUniqueNameForThisNode(cxn.name));
127
+ if (name.match(/^\+\s*[A-Z_]+(\.\d+)?$/)) {
128
+ name = name.toLowerCase();
129
+ }
130
+ ioSlot.name = name;
131
+ ioSlot.type = cxn.type as string;
132
+ ioSlot.removable = true;
133
+ while (!this.outputs[slotIndex]) {
134
+ this.addOutput("*", "*");
135
+ }
136
+ this.outputs[slotIndex]!.type = cxn.type as string;
137
+ this.outputs[slotIndex]!.name = this.stripOwnedPrefix(name).toLocaleUpperCase();
138
+ // This is a dumb override for ComfyUI's widgetinputs issues.
139
+ if (cxn.type === "COMBO" || cxn.type.includes(",") || Array.isArray(cxn.type)) {
140
+ (this.outputs[slotIndex] as any).widget = true;
141
+ }
142
+ this.inputsMutated({
143
+ operation: InputMutationOperation.ADDED,
144
+ node: this,
145
+ slotIndex,
146
+ slot: ioSlot,
147
+ });
148
+ this.stabilizeNames();
149
+ this.ensureOneRemainingNewInputSlot();
150
+ }
151
+ }
152
+
153
+ handleInputDisconnected(slotIndex: number) {
154
+ const inputs = this.getContextInputsList();
155
+ if (slotIndex === 0) {
156
+ for (let index = inputs.length - 1; index > 0; index--) {
157
+ if (index === 0 || index === inputs.length - 1) {
158
+ continue;
159
+ }
160
+ const input = inputs[index]!;
161
+ if (!this.isOwnedInput(input.name)) {
162
+ if (input.link || this.outputs[index]?.links?.length) {
163
+ this.renameContextInput(index, input.name, true);
164
+ } else {
165
+ this.removeContextInput(index);
166
+ }
167
+ }
168
+ }
169
+ this.setSize(this.computeSize());
170
+ this.setDirtyCanvas(true, true);
171
+ }
172
+ }
173
+
174
+ ensureOneRemainingNewInputSlot() {
175
+ removeUnusedInputsFromEnd(this, 1, REGEX_EMPTY_INPUT);
176
+ this.addInput(OWNED_PREFIX, "*");
177
+ }
178
+
179
+ getNextUniqueNameForThisNode(desiredName: string) {
180
+ const inputs = this.getContextInputsList();
181
+ const allExistingKeys = inputs.map((i) => this.stripOwnedPrefix(i.name).toLocaleUpperCase());
182
+ desiredName = this.stripOwnedPrefix(desiredName);
183
+ let newName = desiredName;
184
+ let n = 0;
185
+ while (allExistingKeys.includes(newName.toLocaleUpperCase())) {
186
+ newName = `${desiredName}.${++n}`;
187
+ }
188
+ return newName;
189
+ }
190
+
191
+ override removeInput(slotIndex: number) {
192
+ const slot = this.inputs[slotIndex]!;
193
+ super.removeInput(slotIndex);
194
+ if (this.outputs[slotIndex]) {
195
+ this.removeOutput(slotIndex);
196
+ }
197
+ this.inputsMutated({operation: InputMutationOperation.REMOVED, node: this, slotIndex, slot});
198
+ this.stabilizeNames();
199
+ }
200
+
201
+ stabilizeNames() {
202
+ const inputs = this.getContextInputsList();
203
+ const names: string[] = [];
204
+ for (const [index, input] of inputs.entries()) {
205
+ if (index === 0 || index === inputs.length - 1) {
206
+ continue;
207
+ }
208
+ input.label = undefined;
209
+ this.outputs[index]!.label = undefined;
210
+ let origName = this.stripOwnedPrefix(input.name).replace(/\.\d+$/, "");
211
+ let name = input.name;
212
+ if (!this.isOwnedInput(name)) {
213
+ names.push(name.toLocaleUpperCase());
214
+ } else {
215
+ let n = 0;
216
+ name = this.addOwnedPrefix(origName);
217
+ while (names.includes(this.stripOwnedPrefix(name).toLocaleUpperCase())) {
218
+ name = `${this.addOwnedPrefix(origName)}.${++n}`;
219
+ }
220
+ names.push(this.stripOwnedPrefix(name).toLocaleUpperCase());
221
+ if (input.name !== name) {
222
+ this.renameContextInput(index, name);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ override getSlotMenuOptions(slot: {
229
+ slot: number;
230
+ input?: INodeInputSlot | undefined;
231
+ output?: INodeOutputSlot | undefined;
232
+ }) {
233
+ const editable = this.isOwnedInput(slot.input!.name) && this.type !== "*";
234
+ return [
235
+ {
236
+ content: "✏️ Rename Input",
237
+ disabled: !editable,
238
+ callback: () => {
239
+ var dialog = app.canvas.createDialog(
240
+ "<span class='name'>Name</span><input autofocus type='text'/><button>OK</button>",
241
+ {},
242
+ );
243
+ var dialogInput = dialog.querySelector("input")!;
244
+ if (dialogInput) {
245
+ dialogInput.value = this.stripOwnedPrefix(slot.input!.name || "");
246
+ }
247
+ var inner = () => {
248
+ this.handleContextMenuRenameInputDialog(slot.slot, dialogInput.value);
249
+ dialog.close();
250
+ };
251
+ dialog.querySelector("button")!.addEventListener("click", inner);
252
+ dialogInput.addEventListener("keydown", (e) => {
253
+ dialog.is_modified = true;
254
+ if (e.keyCode == 27) {
255
+ dialog.close();
256
+ } else if (e.keyCode == 13) {
257
+ inner();
258
+ } else if (e.keyCode != 13 && (e.target as HTMLElement)?.localName != "textarea") {
259
+ return;
260
+ }
261
+ e.preventDefault();
262
+ e.stopPropagation();
263
+ });
264
+ dialogInput.focus();
265
+ },
266
+ },
267
+ {
268
+ content: "🗑️ Delete Input",
269
+ disabled: !editable,
270
+ callback: () => {
271
+ this.removeInput(slot.slot);
272
+ },
273
+ },
274
+ ];
275
+ }
276
+
277
+ handleContextMenuRenameInputDialog(slotIndex: number, value: string) {
278
+ app.graph.beforeChange();
279
+ this.renameContextInput(slotIndex, value);
280
+ this.stabilizeNames();
281
+ this.setDirtyCanvas(true, true);
282
+ app.graph.afterChange();
283
+ }
284
+ }
285
+
286
+ const contextDynamicNodes = [DynamicContextNode];
287
+ app.registerExtension({
288
+ name: "rgthree.DynamicContext",
289
+ async beforeRegisterNodeDef(nodeType: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
290
+ if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
291
+ return;
292
+ }
293
+ if (nodeData.name === DynamicContextNode.type) {
294
+ DynamicContextNode.setUp(nodeType, nodeData);
295
+ }
296
+ },
297
+ });
rgthree-comfy/src_web/comfyui/dynamic_context_base.ts ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {INodeInputSlot} from "typings/litegraph.js";
2
+
3
+ import {BaseContextNode} from "./context.js";
4
+ import {ComfyNodeConstructor, ComfyObjectInfo} from "typings/comfy.js";
5
+ import {RgthreeBaseServerNode} from "./base_node.js";
6
+ import {moveArrayItem, wait} from "rgthree/common/shared_utils.js";
7
+ import {RgthreeInvisibleWidget} from "./utils_widgets.js";
8
+ import {
9
+ getContextOutputName,
10
+ InputMutation,
11
+ InputMutationOperation,
12
+ } from "./services/context_service.js";
13
+ import {app} from "scripts/app.js";
14
+ import {SERVICE as CONTEXT_SERVICE} from "./services/context_service.js";
15
+
16
+ const OWNED_PREFIX = "+";
17
+ const REGEX_OWNED_PREFIX = /^\+\s*/;
18
+ const REGEX_EMPTY_INPUT = /^\+\s*$/;
19
+
20
+ export type InputLike = {
21
+ name: string;
22
+ type: string | -1;
23
+ label?: string;
24
+ link: number | null;
25
+ removable?: boolean;
26
+ };
27
+
28
+ /**
29
+ * The base context node that contains some shared between DynamicContext nodes. Not labels
30
+ * `abstract` so we can reference `this` in static methods.
31
+ */
32
+ export class DynamicContextNodeBase extends BaseContextNode {
33
+ protected readonly hasShadowInputs: boolean = false;
34
+
35
+ getContextInputsList(): InputLike[] {
36
+ return this.inputs;
37
+ }
38
+
39
+ provideInputsData() {
40
+ const inputs = this.getContextInputsList();
41
+ return inputs
42
+ .map((input, index) => ({
43
+ name: this.stripOwnedPrefix(input.name),
44
+ type: String(input.type),
45
+ index,
46
+ }))
47
+ .filter((i) => i.type !== "*");
48
+ }
49
+
50
+ addOwnedPrefix(name: string) {
51
+ return `+ ${this.stripOwnedPrefix(name)}`;
52
+ }
53
+
54
+ isOwnedInput(inputOrName: string | null | INodeInputSlot) {
55
+ const name = typeof inputOrName == "string" ? inputOrName : inputOrName?.name || "";
56
+ return REGEX_OWNED_PREFIX.test(name);
57
+ }
58
+
59
+ stripOwnedPrefix(name: string) {
60
+ return name.replace(REGEX_OWNED_PREFIX, "");
61
+ }
62
+
63
+ // handleUpstreamMutation(mutation: InputMutation) {
64
+ // throw new Error('handleUpstreamMutation not overridden!')
65
+ // }
66
+
67
+ handleUpstreamMutation(mutation: InputMutation) {
68
+ console.log(`[node ${this.id}] handleUpstreamMutation`, mutation);
69
+ if (mutation.operation === InputMutationOperation.ADDED) {
70
+ const slot = mutation.slot;
71
+ if (!slot) {
72
+ throw new Error("Cannot have an ADDED mutation without a provided slot data.");
73
+ }
74
+ this.addContextInput(
75
+ this.stripOwnedPrefix(slot.name),
76
+ slot.type as string,
77
+ mutation.slotIndex,
78
+ );
79
+ return;
80
+ }
81
+ if (mutation.operation === InputMutationOperation.REMOVED) {
82
+ const slot = mutation.slot;
83
+ if (!slot) {
84
+ throw new Error("Cannot have an REMOVED mutation without a provided slot data.");
85
+ }
86
+ this.removeContextInput(mutation.slotIndex);
87
+ return;
88
+ }
89
+ if (mutation.operation === InputMutationOperation.RENAMED) {
90
+ const slot = mutation.slot;
91
+ if (!slot) {
92
+ throw new Error("Cannot have an RENAMED mutation without a provided slot data.");
93
+ }
94
+ this.renameContextInput(mutation.slotIndex, slot.name);
95
+ return;
96
+ }
97
+ }
98
+ override clone() {
99
+ const cloned = super.clone();
100
+ while (cloned.inputs.length > 1) {
101
+ cloned.removeInput(cloned.inputs.length - 1);
102
+ }
103
+ while (cloned.widgets.length > 1) {
104
+ cloned.removeWidget(cloned.widgets.length - 1);
105
+ }
106
+ while (cloned.outputs.length > 1) {
107
+ cloned.removeOutput(cloned.outputs.length - 1);
108
+ }
109
+ return cloned;
110
+ }
111
+
112
+ /**
113
+ * Adds the basic output_keys widget. Should be called _after_ specific nodes setup their inputs
114
+ * or widgets.
115
+ */
116
+ override onNodeCreated() {
117
+ const node = this;
118
+ this.addCustomWidget(
119
+ new RgthreeInvisibleWidget("output_keys", "RGTHREE_DYNAMIC_CONTEXT_OUTPUTS", "", () => {
120
+ return (node.outputs || [])
121
+ .map((o, i) => i > 0 && o.name)
122
+ .filter((n) => n !== false)
123
+ .join(",");
124
+ }),
125
+ );
126
+ }
127
+
128
+ addContextInput(name: string, type: string, slot = -1) {
129
+ const inputs = this.getContextInputsList();
130
+ if (this.hasShadowInputs) {
131
+ inputs.push({name, type, link: null});
132
+ } else {
133
+ this.addInput(name, type);
134
+ }
135
+ if (slot > -1) {
136
+ moveArrayItem(inputs, inputs.length - 1, slot);
137
+ } else {
138
+ slot = inputs.length - 1;
139
+ }
140
+ if (type !== "*") {
141
+ const output = this.addOutput(getContextOutputName(name), type);
142
+ if (type === "COMBO" || String(type).includes(",") || Array.isArray(type)) {
143
+ (output as any).widget = true;
144
+ }
145
+ if (slot > -1) {
146
+ moveArrayItem(this.outputs, this.outputs.length - 1, slot);
147
+ }
148
+ }
149
+ this.fixInputsOutputsLinkSlots();
150
+ this.inputsMutated({
151
+ operation: InputMutationOperation.ADDED,
152
+ node: this,
153
+ slotIndex: slot,
154
+ slot: inputs[slot]!,
155
+ });
156
+ }
157
+
158
+ removeContextInput(slotIndex: number) {
159
+ if (this.hasShadowInputs) {
160
+ const inputs = this.getContextInputsList();
161
+ const input = inputs.splice(slotIndex, 1)[0];
162
+ if (this.outputs[slotIndex]) {
163
+ this.removeOutput(slotIndex);
164
+ }
165
+ } else {
166
+ this.removeInput(slotIndex);
167
+ }
168
+ }
169
+
170
+ renameContextInput(index: number, newName: string, forceOwnBool: boolean | null = null) {
171
+ const inputs = this.getContextInputsList();
172
+ const input = inputs[index]!;
173
+ const oldName = input.name;
174
+ newName = this.stripOwnedPrefix(newName.trim() || this.getSlotDefaultInputLabel(index));
175
+ if (forceOwnBool === true || (this.isOwnedInput(oldName) && forceOwnBool !== false)) {
176
+ newName = this.addOwnedPrefix(newName);
177
+ }
178
+ if (oldName !== newName) {
179
+ input.name = newName;
180
+ input.removable = this.isOwnedInput(newName);
181
+ this.outputs[index]!.name = getContextOutputName(inputs[index]!.name);
182
+ this.inputsMutated({
183
+ node: this,
184
+ operation: InputMutationOperation.RENAMED,
185
+ slotIndex: index,
186
+ slot: input,
187
+ });
188
+ }
189
+ }
190
+
191
+ getSlotDefaultInputLabel(slotIndex: number) {
192
+ const inputs = this.getContextInputsList();
193
+ const input = inputs[slotIndex]!;
194
+ let defaultLabel = this.stripOwnedPrefix(input.name).toLowerCase();
195
+ return defaultLabel.toLocaleLowerCase();
196
+ }
197
+
198
+ inputsMutated(mutation: InputMutation) {
199
+ CONTEXT_SERVICE.onInputChanges(this, mutation);
200
+ }
201
+
202
+ fixInputsOutputsLinkSlots() {
203
+ if (!this.hasShadowInputs) {
204
+ const inputs = this.getContextInputsList();
205
+ for (let index = inputs.length - 1; index > 0; index--) {
206
+ const input = inputs[index]!;
207
+ if ((input === null || input === void 0 ? void 0 : input.link) != null) {
208
+ app.graph.links[input.link!]!.target_slot = index;
209
+ }
210
+ }
211
+ }
212
+ const outputs = this.outputs;
213
+ for (let index = outputs.length - 1; index > 0; index--) {
214
+ const output = outputs[index];
215
+ if (output) {
216
+ output.nameLocked = true;
217
+ for (const link of output.links || []) {
218
+ app.graph.links[link!]!.origin_slot = index;
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
225
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, this);
226
+ // [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
227
+ // empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
228
+ // ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
229
+ // because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
230
+ // extension and we need to wait for that to happen.
231
+ wait(500).then(() => {
232
+ LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] =
233
+ LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] || [];
234
+ LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"].push(comfyClass.comfyClass);
235
+ });
236
+ }
237
+ }
rgthree-comfy/src_web/comfyui/dynamic_context_switch.ts ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {ComfyNodeConstructor, ComfyObjectInfo} from "typings/comfy.js";
2
+ import type {INodeSlot, LGraphNode, LLink, LGraphCanvas} from "typings/litegraph.js";
3
+
4
+ import {app} from "scripts/app.js";
5
+ import {DynamicContextNodeBase, InputLike} from "./dynamic_context_base.js";
6
+ import {NodeTypesString} from "./constants.js";
7
+ import {
8
+ InputMutation,
9
+ SERVICE as CONTEXT_SERVICE,
10
+ stripContextInputPrefixes,
11
+ getContextOutputName,
12
+ } from "./services/context_service.js";
13
+ import {getConnectedInputNodesAndFilterPassThroughs} from "./utils.js";
14
+ import {debounce, moveArrayItem} from "rgthree/common/shared_utils.js";
15
+ import {measureText} from "./utils_canvas.js";
16
+ import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
17
+
18
+ type ShadowInputData = {
19
+ node: LGraphNode;
20
+ slot: number;
21
+ shadowIndex: number;
22
+ shadowIndexIfShownSingularly: number;
23
+ shadowIndexFull: number;
24
+ nodeIndex: number;
25
+ type: string | -1;
26
+ name: string;
27
+ key: string;
28
+ // isDuplicatedBefore: boolean,
29
+ duplicatesBefore: number[];
30
+ duplicatesAfter: number[];
31
+ };
32
+
33
+ /**
34
+ * The Context Switch node.
35
+ */
36
+ class DynamicContextSwitchNode extends DynamicContextNodeBase {
37
+ static override title = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
38
+ static override type = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
39
+ static comfyClass = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
40
+
41
+ protected override readonly hasShadowInputs = true;
42
+
43
+ // override hasShadowInputs = true;
44
+
45
+ /**
46
+ * We should be able to assume that `lastInputsList` is the input list after the last, major
47
+ * synchronous change. Which should mean, if we're handling a change that is currently live, but
48
+ * not represented in our node (like, an upstream node has already removed an input), then we
49
+ * should be able to compar the current InputList to this `lastInputsList`.
50
+ */
51
+ lastInputsList: ShadowInputData[] = [];
52
+
53
+ private shadowInputs: (InputLike & {count: number})[] = [
54
+ {name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0},
55
+ ];
56
+
57
+ constructor(title = DynamicContextSwitchNode.title) {
58
+ super(title);
59
+ }
60
+
61
+ override getContextInputsList() {
62
+ return this.shadowInputs;
63
+ }
64
+ override handleUpstreamMutation(mutation: InputMutation) {
65
+ this.scheduleHardRefresh();
66
+ }
67
+
68
+ override onConnectionsChange(
69
+ type: number,
70
+ slotIndex: number,
71
+ isConnected: boolean,
72
+ link: LLink,
73
+ ioSlot: INodeSlot,
74
+ ): void {
75
+ super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, ioSlot);
76
+ if (this.configuring) {
77
+ return;
78
+ }
79
+ if (type === LiteGraph.INPUT) {
80
+ this.scheduleHardRefresh();
81
+ }
82
+ }
83
+
84
+ scheduleHardRefresh(ms = 64) {
85
+ return debounce(() => {
86
+ this.refreshInputsAndOutputs();
87
+ }, ms);
88
+ }
89
+
90
+ override onNodeCreated() {
91
+ this.addInput("ctx_1", "RGTHREE_DYNAMIC_CONTEXT");
92
+ this.addInput("ctx_2", "RGTHREE_DYNAMIC_CONTEXT");
93
+ this.addInput("ctx_3", "RGTHREE_DYNAMIC_CONTEXT");
94
+ this.addInput("ctx_4", "RGTHREE_DYNAMIC_CONTEXT");
95
+ this.addInput("ctx_5", "RGTHREE_DYNAMIC_CONTEXT");
96
+ super.onNodeCreated();
97
+ }
98
+
99
+ override addContextInput(name: string, type: string, slot?: number): void {}
100
+
101
+ /**
102
+ * This is a "hard" refresh of the list, but looping over the actual context inputs, and
103
+ * recompiling the shadowInputs and outputs.
104
+ */
105
+ private refreshInputsAndOutputs() {
106
+ const inputs: (InputLike & {count: number})[] = [
107
+ {name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0},
108
+ ];
109
+ let numConnected = 0;
110
+ for (let i = 0; i < this.inputs.length; i++) {
111
+ const childCtxs = getConnectedInputNodesAndFilterPassThroughs(
112
+ this,
113
+ this,
114
+ i,
115
+ ) as DynamicContextNodeBase[];
116
+ if (childCtxs.length > 1) {
117
+ throw new Error("How is there more than one input?");
118
+ }
119
+ const ctx = childCtxs[0];
120
+ if (!ctx) continue;
121
+ numConnected++;
122
+ const slotsData = CONTEXT_SERVICE.getDynamicContextInputsData(ctx);
123
+ console.log(slotsData);
124
+ for (const slotData of slotsData) {
125
+ const found = inputs.find(
126
+ (n) => getContextOutputName(slotData.name) === getContextOutputName(n.name),
127
+ );
128
+ if (found) {
129
+ found.count += 1;
130
+ continue;
131
+ }
132
+ inputs.push({
133
+ name: slotData.name,
134
+ type: slotData.type,
135
+ link: null,
136
+ count: 1,
137
+ });
138
+ }
139
+ }
140
+ this.shadowInputs = inputs;
141
+ // First output is always CONTEXT, so "p" is the offset.
142
+ let i = 0;
143
+ for (i; i < this.shadowInputs.length; i++) {
144
+ const data = this.shadowInputs[i]!;
145
+ let existing = this.outputs.find(
146
+ (o) => getContextOutputName(o.name) === getContextOutputName(data.name),
147
+ );
148
+ if (!existing) {
149
+ existing = this.addOutput(getContextOutputName(data.name), data.type);
150
+ }
151
+ moveArrayItem(this.outputs, existing, i);
152
+ delete existing.rgthree_status;
153
+ if (data.count !== numConnected) {
154
+ existing.rgthree_status = "WARN";
155
+ }
156
+ }
157
+ while (this.outputs[i]) {
158
+ const output = this.outputs[i];
159
+ if (output?.links?.length) {
160
+ output.rgthree_status = "ERROR";
161
+ i++;
162
+ } else {
163
+ this.removeOutput(i);
164
+ }
165
+ }
166
+ this.fixInputsOutputsLinkSlots();
167
+ }
168
+
169
+ override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: LGraphCanvas): void {
170
+ const low_quality = (canvas?.ds?.scale ?? 1) < 0.6;
171
+ if (low_quality || this.size[0] <= 10) {
172
+ return;
173
+ }
174
+ let y = LiteGraph.NODE_SLOT_HEIGHT - 1;
175
+ const w = this.size[0];
176
+ ctx.save();
177
+ ctx.font = "normal " + LiteGraph.NODE_SUBTEXT_SIZE + "px Arial";
178
+ ctx.textAlign = "right";
179
+
180
+ for (const output of this.outputs) {
181
+ if (!output.rgthree_status) {
182
+ y += LiteGraph.NODE_SLOT_HEIGHT;
183
+ continue;
184
+ }
185
+ const x = w - 20 - measureText(ctx, output.name);
186
+ if (output.rgthree_status === "ERROR") {
187
+ ctx.fillText("🛑", x, y);
188
+ } else if (output.rgthree_status === "WARN") {
189
+ ctx.fillText("⚠️", x, y);
190
+ }
191
+ y += LiteGraph.NODE_SLOT_HEIGHT;
192
+ }
193
+ ctx.restore();
194
+ }
195
+ }
196
+
197
+ app.registerExtension({
198
+ name: "rgthree.DynamicContextSwitch",
199
+ async beforeRegisterNodeDef(nodeType: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
200
+ if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
201
+ return;
202
+ }
203
+ if (nodeData.name === DynamicContextSwitchNode.type) {
204
+ DynamicContextSwitchNode.setUp(nodeType, nodeData);
205
+ }
206
+ },
207
+ });
rgthree-comfy/src_web/comfyui/fast_actions_button.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RgthreeBaseVirtualNodeConstructor } from "typings/rgthree.js";
2
+ import type { ComfyApp, ComfyWidget } from "typings/comfy.js";
3
+ import type { IWidget, LGraph, LGraphNode, SerializedLGraphNode } from "typings/litegraph.js";
4
+ import type { RgthreeBaseNode } from "./base_node.js";
5
+
6
+ import { app } from "scripts/app.js";
7
+ import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js";
8
+ import { NodeTypesString } from "./constants.js";
9
+ import { addMenuItem } from "./utils.js";
10
+ import { rgthree } from "./rgthree.js";
11
+
12
+ const MODE_ALWAYS = 0;
13
+ const MODE_MUTE = 2;
14
+ const MODE_BYPASS = 4;
15
+
16
+ /**
17
+ * The Fast Actions Button.
18
+ *
19
+ * This adds a button that the user can connect any node to and then choose an action to take on
20
+ * that node when the button is pressed. Default actions are "Mute," "Bypass," and "Enable," but
21
+ * Nodes can expose actions additional actions that can then be called back.
22
+ */
23
+ class FastActionsButton extends BaseAnyInputConnectedNode {
24
+ static override type = NodeTypesString.FAST_ACTIONS_BUTTON;
25
+ static override title = NodeTypesString.FAST_ACTIONS_BUTTON;
26
+ override comfyClass = NodeTypesString.FAST_ACTIONS_BUTTON;
27
+
28
+ readonly logger = rgthree.newLogSession("[FastActionsButton]");
29
+
30
+ static "@buttonText" = { type: "string" };
31
+ static "@shortcutModifier" = {
32
+ type: "combo",
33
+ values: ["ctrl", "alt", "shift"],
34
+ };
35
+ static "@shortcutKey" = { type: "string" };
36
+
37
+ static collapsible = false;
38
+
39
+ override readonly isVirtualNode = true;
40
+
41
+ override serialize_widgets = true;
42
+
43
+ readonly buttonWidget: IWidget;
44
+
45
+ readonly widgetToData = new Map<IWidget, { comfy?: ComfyApp; node?: LGraphNode }>();
46
+ readonly nodeIdtoFunctionCache = new Map<number, string>();
47
+
48
+ readonly keypressBound;
49
+ readonly keyupBound;
50
+
51
+ private executingFromShortcut = false;
52
+
53
+ constructor(title?: string) {
54
+ super(title);
55
+ this.properties["buttonText"] = "🎬 Action!";
56
+ this.properties["shortcutModifier"] = "alt";
57
+ this.properties["shortcutKey"] = "";
58
+ this.buttonWidget = this.addWidget(
59
+ "button",
60
+ this.properties["buttonText"],
61
+ null,
62
+ () => {
63
+ this.executeConnectedNodes();
64
+ },
65
+ { serialize: false },
66
+ );
67
+
68
+ this.keypressBound = this.onKeypress.bind(this);
69
+ this.keyupBound = this.onKeyup.bind(this);
70
+ this.onConstructed();
71
+ }
72
+
73
+ /** When we're given data to configure, like from a PNG or JSON. */
74
+ override configure(info: SerializedLGraphNode<LGraphNode>): void {
75
+ super.configure(info);
76
+ // Since we add the widgets dynamically, we need to wait to set their values
77
+ // with a short timeout.
78
+ setTimeout(() => {
79
+ if (info.widgets_values) {
80
+ for (let [index, value] of info.widgets_values.entries()) {
81
+ if (index > 0) {
82
+ if (value.startsWith("comfy_action:")) {
83
+ value = value.replace("comfy_action:", "");
84
+ this.addComfyActionWidget(index, value);
85
+ }
86
+ if (this.widgets[index]) {
87
+ this.widgets[index]!.value = value;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }, 100);
93
+ }
94
+
95
+ override clone() {
96
+ const cloned = super.clone();
97
+ cloned.properties["buttonText"] = "🎬 Action!";
98
+ cloned.properties["shortcutKey"] = "";
99
+ return cloned;
100
+ }
101
+
102
+ override onAdded(graph: LGraph): void {
103
+ window.addEventListener("keydown", this.keypressBound);
104
+ window.addEventListener("keyup", this.keyupBound);
105
+ }
106
+
107
+ override onRemoved(): void {
108
+ window.removeEventListener("keydown", this.keypressBound);
109
+ window.removeEventListener("keyup", this.keyupBound);
110
+ }
111
+
112
+ async onKeypress(event: KeyboardEvent) {
113
+ const target = (event.target as HTMLElement)!;
114
+ if (
115
+ this.executingFromShortcut ||
116
+ target.localName == "input" ||
117
+ target.localName == "textarea"
118
+ ) {
119
+ return;
120
+ }
121
+ if (
122
+ this.properties["shortcutKey"].trim() &&
123
+ this.properties["shortcutKey"].toLowerCase() === event.key.toLowerCase()
124
+ ) {
125
+ const shortcutModifier = this.properties["shortcutModifier"];
126
+ let good = shortcutModifier === "ctrl" && event.ctrlKey;
127
+ good = good || (shortcutModifier === "alt" && event.altKey);
128
+ good = good || (shortcutModifier === "shift" && event.shiftKey);
129
+ good = good || (shortcutModifier === "meta" && event.metaKey);
130
+ if (good) {
131
+ setTimeout(() => {
132
+ this.executeConnectedNodes();
133
+ }, 20);
134
+ this.executingFromShortcut = true;
135
+ event.preventDefault();
136
+ event.stopImmediatePropagation();
137
+ app.canvas.dirty_canvas = true;
138
+ return false;
139
+ }
140
+ }
141
+ return;
142
+ }
143
+
144
+ onKeyup(event: KeyboardEvent) {
145
+ const target = (event.target as HTMLElement)!;
146
+ if (target.localName == "input" || target.localName == "textarea") {
147
+ return;
148
+ }
149
+ this.executingFromShortcut = false;
150
+ }
151
+
152
+ override onPropertyChanged(property: string, value: any, _prevValue: any): boolean | void {
153
+ if (property == "buttonText") {
154
+ this.buttonWidget.name = value;
155
+ }
156
+ if (property == "shortcutKey") {
157
+ value = value.trim();
158
+ this.properties["shortcutKey"] = (value && value[0].toLowerCase()) || "";
159
+ }
160
+ }
161
+
162
+ override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
163
+ let changed = false;
164
+ // Remove any widgets and data for widgets that are no longer linked.
165
+ for (const [widget, data] of this.widgetToData.entries()) {
166
+ if (!data.node) {
167
+ continue;
168
+ }
169
+ if (!linkedNodes.includes(data.node)) {
170
+ const index = this.widgets.indexOf(widget);
171
+ if (index > -1) {
172
+ this.widgetToData.delete(widget);
173
+ this.removeWidget(widget);
174
+ changed = true;
175
+ } else {
176
+ const [m, a] = this.logger.debugParts("Connected widget is not in widgets... weird.");
177
+ console[m]?.(...a);
178
+ }
179
+ }
180
+ }
181
+
182
+ const badNodes: LGraphNode[] = []; // Nodes that are deleted elsewhere may not exist in linkedNodes.
183
+ let indexOffset = 1; // Start with button, increment when we hit a non-node widget (like comfy)
184
+ for (const [index, node] of linkedNodes.entries()) {
185
+ // Sometimes linkedNodes is stale.
186
+ if (!node) {
187
+ const [m, a] = this.logger.debugParts("linkedNode provided that does not exist. ");
188
+ console[m]?.(...a);
189
+ badNodes.push(node);
190
+ continue;
191
+ }
192
+ let widgetAtSlot = this.widgets[index + indexOffset];
193
+ if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
194
+ indexOffset++;
195
+ widgetAtSlot = this.widgets[index + indexOffset];
196
+ }
197
+
198
+ if (!widgetAtSlot || this.widgetToData.get(widgetAtSlot)?.node?.id !== node.id) {
199
+ // Find the next widget that matches the node.
200
+ let widget: IWidget | null = null;
201
+ for (let i = index + indexOffset; i < this.widgets.length; i++) {
202
+ if (this.widgetToData.get(this.widgets[i]!)?.node?.id === node.id) {
203
+ widget = this.widgets.splice(i, 1)[0]!;
204
+ this.widgets.splice(index + indexOffset, 0, widget);
205
+ changed = true;
206
+ break;
207
+ }
208
+ }
209
+ if (!widget) {
210
+ // Add a widget at this spot.
211
+ const exposedActions: string[] = (node.constructor as any).exposedActions || [];
212
+ widget = this.addWidget("combo", node.title, "None", "", {
213
+ values: ["None", "Mute", "Bypass", "Enable", ...exposedActions],
214
+ });
215
+ (widget as ComfyWidget).serializeValue = async (_node: LGraphNode, _index: number) => {
216
+ return widget?.value;
217
+ };
218
+ this.widgetToData.set(widget, { node });
219
+ changed = true;
220
+ }
221
+ }
222
+ }
223
+
224
+ // Go backwards through widgets, and remove any that are not in out widgetToData
225
+ for (let i = this.widgets.length - 1; i > linkedNodes.length + indexOffset - 1; i--) {
226
+ const widgetAtSlot = this.widgets[i];
227
+ if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
228
+ continue;
229
+ }
230
+ this.removeWidget(widgetAtSlot);
231
+ changed = true;
232
+ }
233
+ return changed;
234
+ }
235
+
236
+ override removeWidget(widgetOrSlot?: number | IWidget): void {
237
+ const widget = typeof widgetOrSlot === "number" ? this.widgets[widgetOrSlot] : widgetOrSlot;
238
+ if (widget && this.widgetToData.has(widget)) {
239
+ this.widgetToData.delete(widget);
240
+ }
241
+ super.removeWidget(widgetOrSlot);
242
+ }
243
+
244
+ /**
245
+ * Runs through the widgets, and executes the actions.
246
+ */
247
+ async executeConnectedNodes() {
248
+ for (const widget of this.widgets) {
249
+ if (widget == this.buttonWidget) {
250
+ continue;
251
+ }
252
+ const action = widget.value;
253
+ const { comfy, node } = this.widgetToData.get(widget) ?? {};
254
+ if (comfy) {
255
+ if (action === "Queue Prompt") {
256
+ await comfy.queuePrompt(0);
257
+ }
258
+ continue;
259
+ }
260
+ if (node) {
261
+ if (action === "Mute") {
262
+ node.mode = MODE_MUTE;
263
+ } else if (action === "Bypass") {
264
+ node.mode = MODE_BYPASS;
265
+ } else if (action === "Enable") {
266
+ node.mode = MODE_ALWAYS;
267
+ }
268
+ // If there's a handleAction, always call it.
269
+ if ((node as RgthreeBaseNode).handleAction) {
270
+ await (node as RgthreeBaseNode).handleAction(action);
271
+ }
272
+ app.graph.change();
273
+ continue;
274
+ }
275
+ console.warn("Fast Actions Button has a widget without correct data.");
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Adds a ComfyActionWidget at the provided slot (or end).
281
+ */
282
+ addComfyActionWidget(slot?: number, value?: string) {
283
+ let widget = this.addWidget(
284
+ "combo",
285
+ "Comfy Action",
286
+ "None",
287
+ () => {
288
+ if (widget.value.startsWith("MOVE ")) {
289
+ this.widgets.push(this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
290
+ widget.value = (widget as any)["lastValue_"];
291
+ } else if (widget.value.startsWith("REMOVE ")) {
292
+ this.removeWidget(widget);
293
+ }
294
+ (widget as any)["lastValue_"] = widget.value;
295
+ },
296
+ {
297
+ values: ["None", "Queue Prompt", "REMOVE Comfy Action", "MOVE to end"],
298
+ },
299
+ );
300
+ (widget as any)["lastValue_"] = value;
301
+
302
+ (widget as ComfyWidget).serializeValue = async (_node: LGraphNode, _index: number) => {
303
+ return `comfy_app:${widget?.value}`;
304
+ };
305
+ this.widgetToData.set(widget, { comfy: app });
306
+
307
+ if (slot != null) {
308
+ this.widgets.splice(slot, 0, this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
309
+ }
310
+ return widget;
311
+ }
312
+
313
+ override onSerialize(o: SerializedLGraphNode) {
314
+ super.onSerialize && super.onSerialize(o);
315
+ for (let [index, value] of (o.widgets_values || []).entries()) {
316
+ if (this.widgets[index]?.name === "Comfy Action") {
317
+ o.widgets_values![index] = `comfy_action:${value}`;
318
+ }
319
+ }
320
+ }
321
+
322
+ static override setUp() {
323
+ super.setUp();
324
+ addMenuItem(this, app, {
325
+ name: "➕ Append a Comfy Action",
326
+ callback: (nodeArg: LGraphNode) => {
327
+ (nodeArg as FastActionsButton).addComfyActionWidget();
328
+ },
329
+ });
330
+ }
331
+ }
332
+
333
+ app.registerExtension({
334
+ name: "rgthree.FastActionsButton",
335
+ registerCustomNodes() {
336
+ FastActionsButton.setUp();
337
+ },
338
+ loadedGraphNode(node: LGraphNode) {
339
+ if (node.type == FastActionsButton.title) {
340
+ (node as FastActionsButton)._tempWidth = node.size[0];
341
+ }
342
+ },
343
+ });
rgthree-comfy/src_web/comfyui/fast_groups_bypasser.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { RgthreeBaseVirtualNodeConstructor } from "typings/rgthree.js";
2
+ import { app } from "scripts/app.js";
3
+ import { NodeTypesString } from "./constants.js";
4
+ import { BaseFastGroupsModeChanger } from "./fast_groups_muter.js";
5
+
6
+ /**
7
+ * Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
8
+ */
9
+ export class FastGroupsBypasser extends BaseFastGroupsModeChanger {
10
+ static override type = NodeTypesString.FAST_GROUPS_BYPASSER;
11
+ static override title = NodeTypesString.FAST_GROUPS_BYPASSER;
12
+ override comfyClass = NodeTypesString.FAST_GROUPS_BYPASSER;
13
+
14
+ static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
15
+
16
+ protected override helpActions = "bypass and enable";
17
+
18
+ override readonly modeOn = LiteGraph.ALWAYS;
19
+ override readonly modeOff = 4; // Used by Comfy for "bypass"
20
+
21
+ constructor(title = FastGroupsBypasser.title) {
22
+ super(title);
23
+ this.onConstructed();
24
+ }
25
+ }
26
+
27
+ app.registerExtension({
28
+ name: "rgthree.FastGroupsBypasser",
29
+ registerCustomNodes() {
30
+ FastGroupsBypasser.setUp();
31
+ },
32
+ loadedGraphNode(node: FastGroupsBypasser) {
33
+ if (node.type == FastGroupsBypasser.title) {
34
+ node.tempSize = [...node.size];
35
+ }
36
+ },
37
+ });
rgthree-comfy/src_web/comfyui/fast_groups_muter.ts ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { RgthreeBaseVirtualNode } from "./base_node.js";
3
+ import { NodeTypesString } from "./constants.js";
4
+ import {
5
+ type LGraphNode,
6
+ type LGraph as TLGraph,
7
+ LGraphCanvas as TLGraphCanvas,
8
+ Vector2,
9
+ SerializedLGraphNode,
10
+ IWidget,
11
+ } from "typings/litegraph.js";
12
+ import { SERVICE as FAST_GROUPS_SERVICE } from "./services/fast_groups_service.js";
13
+ import { drawNodeWidget, fitString } from "./utils_canvas.js";
14
+ import { RgthreeBaseVirtualNodeConstructor } from "typings/rgthree.js";
15
+
16
+ const PROPERTY_SORT = "sort";
17
+ const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
18
+ const PROPERTY_MATCH_COLORS = "matchColors";
19
+ const PROPERTY_MATCH_TITLE = "matchTitle";
20
+ const PROPERTY_SHOW_NAV = "showNav";
21
+ const PROPERTY_RESTRICTION = "toggleRestriction";
22
+
23
+ /**
24
+ * Fast Muter implementation that looks for groups in the workflow and adds toggles to mute them.
25
+ */
26
+ export abstract class BaseFastGroupsModeChanger extends RgthreeBaseVirtualNode {
27
+ static override type = NodeTypesString.FAST_GROUPS_MUTER;
28
+ static override title = NodeTypesString.FAST_GROUPS_MUTER;
29
+
30
+ static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
31
+
32
+ readonly modeOn: number = LiteGraph.ALWAYS;
33
+ readonly modeOff: number = LiteGraph.NEVER;
34
+
35
+ private debouncerTempWidth: number = 0;
36
+ tempSize: Vector2 | null = null;
37
+
38
+ // We don't need to serizalize since we'll just be checking group data on startup anyway
39
+ override serialize_widgets = false;
40
+
41
+ protected helpActions = "mute and unmute";
42
+
43
+ static "@matchColors" = { type: "string" };
44
+ static "@matchTitle" = { type: "string" };
45
+ static "@showNav" = { type: "boolean" };
46
+ static "@sort" = {
47
+ type: "combo",
48
+ values: ["position", "alphanumeric", "custom alphabet"],
49
+ };
50
+ static "@customSortAlphabet" = { type: "string" };
51
+
52
+ static "@toggleRestriction" = {
53
+ type: "combo",
54
+ values: ["default", "max one", "always one"],
55
+ };
56
+
57
+ constructor(title = FastGroupsMuter.title) {
58
+ super(title);
59
+ this.properties[PROPERTY_MATCH_COLORS] = "";
60
+ this.properties[PROPERTY_MATCH_TITLE] = "";
61
+ this.properties[PROPERTY_SHOW_NAV] = true;
62
+ this.properties[PROPERTY_SORT] = "position";
63
+ this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
64
+ this.properties[PROPERTY_RESTRICTION] = "default";
65
+ }
66
+
67
+ override onConstructed(): boolean {
68
+ this.addOutput("OPT_CONNECTION", "*");
69
+ return super.onConstructed();
70
+ }
71
+
72
+ override configure(info: SerializedLGraphNode<LGraphNode>): void {
73
+ // Patch a small issue (~14h) where multiple OPT_CONNECTIONS may have been created.
74
+ // https://github.com/rgthree/rgthree-comfy/issues/206
75
+ // TODO: This can probably be removed within a few weeks.
76
+ if (info.outputs?.length) {
77
+ info.outputs.length = 1;
78
+ }
79
+ super.configure(info);
80
+ }
81
+
82
+ override onAdded(graph: TLGraph): void {
83
+ FAST_GROUPS_SERVICE.addFastGroupNode(this);
84
+ }
85
+
86
+ override onRemoved(): void {
87
+ FAST_GROUPS_SERVICE.removeFastGroupNode(this);
88
+ }
89
+
90
+ refreshWidgets() {
91
+ const canvas = app.canvas as TLGraphCanvas;
92
+ let sort = this.properties?.[PROPERTY_SORT] || "position";
93
+ let customAlphabet: string[] | null = null;
94
+ if (sort === "custom alphabet") {
95
+ const customAlphaStr = this.properties?.[PROPERTY_SORT_CUSTOM_ALPHA]?.replace(/\n/g, "");
96
+ if (customAlphaStr && customAlphaStr.trim()) {
97
+ customAlphabet = customAlphaStr.includes(",")
98
+ ? customAlphaStr.toLocaleLowerCase().split(",")
99
+ : customAlphaStr.toLocaleLowerCase().trim().split("");
100
+ }
101
+ if (!customAlphabet?.length) {
102
+ sort = "alphanumeric";
103
+ customAlphabet = null;
104
+ }
105
+ }
106
+
107
+ const groups = [...FAST_GROUPS_SERVICE.getGroups(sort)];
108
+ // The service will return pre-sorted groups for alphanumeric and position. If this node has a
109
+ // custom sort, then we need to sort it manually.
110
+ if (customAlphabet?.length) {
111
+ groups.sort((a, b) => {
112
+ let aIndex = -1;
113
+ let bIndex = -1;
114
+ // Loop and find indexes. As we're finding multiple, a single for loop is more efficient.
115
+ for (const [index, alpha] of customAlphabet!.entries()) {
116
+ aIndex =
117
+ aIndex < 0 ? (a.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : aIndex;
118
+ bIndex =
119
+ bIndex < 0 ? (b.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : bIndex;
120
+ if (aIndex > -1 && bIndex > -1) {
121
+ break;
122
+ }
123
+ }
124
+ // Now compare.
125
+ if (aIndex > -1 && bIndex > -1) {
126
+ const ret = aIndex - bIndex;
127
+ if (ret === 0) {
128
+ return a.title.localeCompare(b.title);
129
+ }
130
+ return ret;
131
+ } else if (aIndex > -1) {
132
+ return -1;
133
+ } else if (bIndex > -1) {
134
+ return 1;
135
+ }
136
+ return a.title.localeCompare(b.title);
137
+ });
138
+ }
139
+
140
+ // See if we're filtering by colors, and match against the built-in keywords and actuial hex
141
+ // values.
142
+ let filterColors = (
143
+ (this.properties?.[PROPERTY_MATCH_COLORS] as string)?.split(",") || []
144
+ ).filter((c) => c.trim());
145
+ if (filterColors.length) {
146
+ filterColors = filterColors.map((color) => {
147
+ color = color.trim().toLocaleLowerCase();
148
+ if (LGraphCanvas.node_colors[color]) {
149
+ color = LGraphCanvas.node_colors[color]!.groupcolor;
150
+ }
151
+ color = color.replace("#", "").toLocaleLowerCase();
152
+ if (color.length === 3) {
153
+ color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
154
+ }
155
+ return `#${color}`;
156
+ });
157
+ }
158
+
159
+ // Go over the groups
160
+ let index = 0;
161
+ for (const group of groups) {
162
+ if (filterColors.length) {
163
+ let groupColor = group.color?.replace("#", "").trim().toLocaleLowerCase();
164
+ if (!groupColor) {
165
+ continue;
166
+ }
167
+ if (groupColor.length === 3) {
168
+ groupColor = groupColor.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
169
+ }
170
+ groupColor = `#${groupColor}`;
171
+ if (!filterColors.includes(groupColor)) {
172
+ continue;
173
+ }
174
+ }
175
+ if (this.properties?.[PROPERTY_MATCH_TITLE]?.trim()) {
176
+ try {
177
+ if (!new RegExp(this.properties[PROPERTY_MATCH_TITLE], "i").exec(group.title)) {
178
+ continue;
179
+ }
180
+ } catch (e) {
181
+ console.error(e);
182
+ continue;
183
+ }
184
+ }
185
+ const widgetName = `Enable ${group.title}`;
186
+ let widget = this.widgets.find((w) => w.name === widgetName);
187
+ if (!widget) {
188
+ // When we add a widget, litegraph is going to mess up the size, so we
189
+ // store it so we can retrieve it in computeSize. Hacky..
190
+ this.tempSize = [...this.size];
191
+ widget = this.addCustomWidget<IWidget<boolean>>({
192
+ name: "RGTHREE_TOGGLE_AND_NAV",
193
+ label: "",
194
+ value: false,
195
+ disabled: false,
196
+ options: { on: "yes", off: "no" },
197
+ draw: function (
198
+ ctx: CanvasRenderingContext2D,
199
+ node: LGraphNode,
200
+ width: number,
201
+ posY: number,
202
+ height: number,
203
+ ) {
204
+ const widgetData = drawNodeWidget(ctx, {
205
+ width,
206
+ height,
207
+ posY,
208
+ });
209
+
210
+ const showNav = node.properties?.[PROPERTY_SHOW_NAV] !== false;
211
+
212
+ // Render from right to left, since the text on left will take available space.
213
+ // `currentX` markes the current x position moving backwards.
214
+ let currentX = widgetData.width - widgetData.margin;
215
+
216
+ // The nav arrow
217
+ if (!widgetData.lowQuality && showNav) {
218
+ currentX -= 7; // Arrow space margin
219
+ const midY = widgetData.posY + widgetData.height * 0.5;
220
+ ctx.fillStyle = ctx.strokeStyle = "#89A";
221
+ ctx.lineJoin = "round";
222
+ ctx.lineCap = "round";
223
+ const arrow = new Path2D(`M${currentX} ${midY} l -7 6 v -3 h -7 v -6 h 7 v -3 z`);
224
+ ctx.fill(arrow);
225
+ ctx.stroke(arrow);
226
+ currentX -= 14;
227
+
228
+ currentX -= 7;
229
+ ctx.strokeStyle = widgetData.colorOutline;
230
+ ctx.stroke(new Path2D(`M ${currentX} ${widgetData.posY} v ${widgetData.height}`));
231
+ } else if (widgetData.lowQuality && showNav) {
232
+ currentX -= 28;
233
+ }
234
+
235
+ // The toggle itself.
236
+ currentX -= 7;
237
+ ctx.fillStyle = this.value ? "#89A" : "#333";
238
+ ctx.beginPath();
239
+ const toggleRadius = height * 0.36;
240
+ ctx.arc(currentX - toggleRadius, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
241
+ ctx.fill();
242
+ currentX -= toggleRadius * 2;
243
+
244
+ if (!widgetData.lowQuality) {
245
+ currentX -= 4;
246
+ ctx.textAlign = "right";
247
+ ctx.fillStyle = this.value ? widgetData.colorText : widgetData.colorTextSecondary;
248
+ const label = this.label || this.name;
249
+ const toggleLabelOn = this.options.on || "true";
250
+ const toggleLabelOff = this.options.off || "false";
251
+ ctx.fillText(
252
+ this.value ? toggleLabelOn : toggleLabelOff,
253
+ currentX,
254
+ posY + height * 0.7,
255
+ );
256
+ currentX -= Math.max(
257
+ ctx.measureText(toggleLabelOn).width,
258
+ ctx.measureText(toggleLabelOff).width,
259
+ );
260
+
261
+ currentX -= 7;
262
+ ctx.textAlign = "left";
263
+ let maxLabelWidth =
264
+ widgetData.width - widgetData.margin - 10 - (widgetData.width - currentX);
265
+ if (label != null) {
266
+ ctx.fillText(
267
+ fitString(ctx, label, maxLabelWidth),
268
+ widgetData.margin + 10,
269
+ posY + height * 0.7,
270
+ );
271
+ }
272
+ }
273
+ },
274
+ serializeValue(serializedNode: SerializedLGraphNode, widgetIndex: number) {
275
+ return this.value;
276
+ },
277
+ mouse(event: PointerEvent, pos: Vector2, node: LGraphNode) {
278
+ if (event.type == "pointerdown") {
279
+ if (
280
+ node.properties?.[PROPERTY_SHOW_NAV] !== false &&
281
+ pos[0] >= node.size[0] - 15 - 28 - 1
282
+ ) {
283
+ const canvas = app.canvas as TLGraphCanvas;
284
+ const lowQuality = (canvas.ds?.scale || 1) <= 0.5;
285
+ if (!lowQuality) {
286
+ // Clicked on right half with nav arrow, go to the group, center on group and set
287
+ // zoom to see it all.
288
+ canvas.centerOnNode(group);
289
+ const zoomCurrent = canvas.ds?.scale || 1;
290
+ const zoomX = canvas.canvas.width / group._size[0] - 0.02;
291
+ const zoomY = canvas.canvas.height / group._size[1] - 0.02;
292
+ canvas.setZoom(Math.min(zoomCurrent, zoomX, zoomY), [
293
+ canvas.canvas.width / 2,
294
+ canvas.canvas.height / 2,
295
+ ]);
296
+ canvas.setDirty(true, true);
297
+ }
298
+ } else {
299
+ this.value = !this.value;
300
+ setTimeout(() => {
301
+ this.callback?.(this.value, app.canvas, node, pos, event);
302
+ }, 20);
303
+ }
304
+ }
305
+ return true;
306
+ },
307
+ });
308
+ (widget as any).doModeChange = (force?: boolean, skipOtherNodeCheck?: boolean) => {
309
+ group.recomputeInsideNodes();
310
+ const hasAnyActiveNodes = group._nodes.some((n) => n.mode === LiteGraph.ALWAYS);
311
+ let newValue = force != null ? force : !hasAnyActiveNodes;
312
+ if (skipOtherNodeCheck !== true) {
313
+ if (newValue && this.properties?.[PROPERTY_RESTRICTION]?.includes(" one")) {
314
+ for (const widget of this.widgets) {
315
+ (widget as any).doModeChange(false, true);
316
+ }
317
+ } else if (!newValue && this.properties?.[PROPERTY_RESTRICTION] === "always one") {
318
+ newValue = this.widgets.every((w) => !w.value || w === widget);
319
+ }
320
+ }
321
+ for (const node of group._nodes) {
322
+ node.mode = (newValue ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4;
323
+ }
324
+ (group as any)._rgthreeHasAnyActiveNode = newValue;
325
+ widget!.value = newValue;
326
+ app.graph.setDirtyCanvas(true, false);
327
+ };
328
+ widget.callback = () => {
329
+ (widget as any).doModeChange();
330
+ };
331
+
332
+ this.setSize(this.computeSize());
333
+ }
334
+ if (widget.name != widgetName) {
335
+ widget.name = widgetName;
336
+ this.setDirtyCanvas(true, false);
337
+ }
338
+ if (widget.value != (group as any)._rgthreeHasAnyActiveNode) {
339
+ widget.value = (group as any)._rgthreeHasAnyActiveNode;
340
+ this.setDirtyCanvas(true, false);
341
+ }
342
+ if (this.widgets[index] !== widget) {
343
+ const oldIndex = this.widgets.findIndex((w) => w === widget);
344
+ this.widgets.splice(index, 0, this.widgets.splice(oldIndex, 1)[0]!);
345
+ this.setDirtyCanvas(true, false);
346
+ }
347
+ index++;
348
+ }
349
+
350
+ // Everything should now be in order, so let's remove all remaining widgets.
351
+ while ((this.widgets || [])[index]) {
352
+ this.removeWidget(index++);
353
+ }
354
+ }
355
+
356
+ override computeSize(out?: Vector2) {
357
+ let size = super.computeSize(out);
358
+ if (this.tempSize) {
359
+ size[0] = Math.max(this.tempSize[0], size[0]);
360
+ size[1] = Math.max(this.tempSize[1], size[1]);
361
+ // We sometimes get repeated calls to compute size, so debounce before clearing.
362
+ this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
363
+ this.debouncerTempWidth = setTimeout(() => {
364
+ this.tempSize = null;
365
+ }, 32);
366
+ }
367
+ setTimeout(() => {
368
+ app.graph.setDirtyCanvas(true, true);
369
+ }, 16);
370
+ return size;
371
+ }
372
+
373
+ override async handleAction(action: string) {
374
+ if (action === "Mute all" || action === "Bypass all") {
375
+ const alwaysOne = this.properties?.[PROPERTY_RESTRICTION] === "always one";
376
+ for (const [index, widget] of this.widgets.entries()) {
377
+ (widget as any)?.doModeChange(alwaysOne && !index ? true : false, true);
378
+ }
379
+ } else if (action === "Enable all") {
380
+ const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
381
+ for (const [index, widget] of this.widgets.entries()) {
382
+ (widget as any)?.doModeChange(onlyOne && index > 0 ? false : true, true);
383
+ }
384
+ } else if (action === "Toggle all") {
385
+ const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
386
+ let foundOne = false;
387
+ for (const [index, widget] of this.widgets.entries()) {
388
+ // If you have only one, then we'll stop at the first.
389
+ let newValue: boolean = onlyOne && foundOne ? false : !widget.value;
390
+ foundOne = foundOne || newValue;
391
+ (widget as any)?.doModeChange(newValue, true);
392
+ }
393
+ // And if you have always one, then we'll flip the last
394
+ if (!foundOne && this.properties?.[PROPERTY_RESTRICTION] === "always one") {
395
+ (this.widgets[this.widgets.length - 1] as any)?.doModeChange(true, true);
396
+ }
397
+ }
398
+ }
399
+
400
+ override getHelp() {
401
+ return `
402
+ <p>The ${this.type!.replace(
403
+ "(rgthree)",
404
+ "",
405
+ )} is an input-less node that automatically collects all groups in your current
406
+ workflow and allows you to quickly ${this.helpActions} all nodes within the group.</p>
407
+ <ul>
408
+ <li>
409
+ <p>
410
+ <strong>Properties.</strong> You can change the following properties (by right-clicking
411
+ on the node, and select "Properties" or "Properties Panel" from the menu):
412
+ </p>
413
+ <ul>
414
+ <li><p>
415
+ <code>${PROPERTY_MATCH_COLORS}</code> - Only add groups that match the provided
416
+ colors. Can be ComfyUI colors (red, pale_blue) or hex codes (#a4d399). Multiple can be
417
+ added, comma delimited.
418
+ </p></li>
419
+ <li><p>
420
+ <code>${PROPERTY_MATCH_TITLE}</code> - Filter the list of toggles by title match
421
+ (string match, or regular expression).
422
+ </p></li>
423
+ <li><p>
424
+ <code>${PROPERTY_SHOW_NAV}</code> - Add / remove a quick navigation arrow to take you
425
+ to the group. <i>(default: true)</i>
426
+ </p></li>
427
+ <li><p>
428
+ <code>${PROPERTY_SORT}</code> - Sort the toggles' order by "alphanumeric", graph
429
+ "position", or "custom alphabet". <i>(default: "position")</i>
430
+ </p></li>
431
+ <li>
432
+ <p>
433
+ <code>${PROPERTY_SORT_CUSTOM_ALPHA}</code> - When the
434
+ <code>${PROPERTY_SORT}</code> property is "custom alphabet" you can define the
435
+ alphabet to use here, which will match the <i>beginning</i> of each group name and
436
+ sort against it. If group titles do not match any custom alphabet entry, then they
437
+ will be put after groups that do, ordered alphanumerically.
438
+ </p>
439
+ <p>
440
+ This can be a list of single characters, like "zyxw..." or comma delimited strings
441
+ for more control, like "sdxl,pro,sd,n,p".
442
+ </p>
443
+ <p>
444
+ Note, when two group title match the same custom alphabet entry, the <i>normal
445
+ alphanumeric alphabet</i> breaks the tie. For instance, a custom alphabet of
446
+ "e,s,d" will order groups names like "SDXL, SEGS, Detailer" eventhough the custom
447
+ alphabet has an "e" before "d" (where one may expect "SE" to be before "SD").
448
+ </p>
449
+ <p>
450
+ To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the
451
+ custom alphabet value of "se,s,f" would work here.
452
+ </p>
453
+ </li>
454
+ <li><p>
455
+ <code>${PROPERTY_RESTRICTION}</code> - Optionally, attempt to restrict the number of
456
+ widgets that can be enabled to a maximum of one, or always one.
457
+ </p>
458
+ <p><em><strong>Note:</strong> If using "max one" or "always one" then this is only
459
+ enforced when clicking a toggle on this node; if nodes within groups are changed
460
+ outside of the initial toggle click, then these restriction will not be enforced, and
461
+ could result in a state where more than one toggle is enabled. This could also happen
462
+ if nodes are overlapped with multiple groups.
463
+ </p></li>
464
+
465
+ </ul>
466
+ </li>
467
+ </ul>`;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
473
+ */
474
+ export class FastGroupsMuter extends BaseFastGroupsModeChanger {
475
+ static override type = NodeTypesString.FAST_GROUPS_MUTER;
476
+ static override title = NodeTypesString.FAST_GROUPS_MUTER;
477
+ override comfyClass = NodeTypesString.FAST_GROUPS_MUTER;
478
+
479
+ static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
480
+
481
+ protected override helpActions = "mute and unmute";
482
+
483
+ override readonly modeOn: number = LiteGraph.ALWAYS;
484
+ override readonly modeOff: number = LiteGraph.NEVER;
485
+
486
+ constructor(title = FastGroupsMuter.title) {
487
+ super(title);
488
+ this.onConstructed();
489
+ }
490
+ }
491
+
492
+ app.registerExtension({
493
+ name: "rgthree.FastGroupsMuter",
494
+ registerCustomNodes() {
495
+ FastGroupsMuter.setUp();
496
+ },
497
+ loadedGraphNode(node: LGraphNode) {
498
+ if (node.type == FastGroupsMuter.title) {
499
+ (node as FastGroupsMuter).tempSize = [...node.size];
500
+ }
501
+ },
502
+ });
rgthree-comfy/src_web/comfyui/feature_group_fast_toggle.ts ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ LGraphCanvas as TLGraphCanvas,
3
+ LGraphGroup as TLGraphGroup,
4
+ LGraph as TLGraph,
5
+ AdjustedMouseEvent,
6
+ Vector2,
7
+ } from "typings/litegraph.js";
8
+ import type {AdjustedMouseCustomEvent} from "typings/rgthree.js";
9
+
10
+ import {app} from "scripts/app.js";
11
+ import {rgthree} from "./rgthree.js";
12
+ import {getOutputNodes} from "./utils.js";
13
+ import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
14
+
15
+ const BTN_SIZE = 20;
16
+ const BTN_MARGIN: Vector2 = [6, 6];
17
+ const BTN_SPACING = 8;
18
+ const BTN_GRID = BTN_SIZE / 8;
19
+
20
+ const TOGGLE_TO_MODE = new Map([
21
+ ["MUTE", LiteGraph.NEVER],
22
+ ["BYPASS", 4],
23
+ ]);
24
+
25
+ function getToggles() {
26
+ return [...CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.toggles", [])].reverse();
27
+ }
28
+
29
+ /**
30
+ * Determines if the user clicked on an fast header icon.
31
+ */
32
+ function clickedOnToggleButton(e: AdjustedMouseEvent, group: TLGraphGroup): string | null {
33
+ const toggles = getToggles();
34
+ const pos = group.pos;
35
+ const size = group.size;
36
+ for (let i = 0; i < toggles.length; i++) {
37
+ const toggle = toggles[i];
38
+ if (
39
+ LiteGraph.isInsideRectangle(
40
+ e.canvasX,
41
+ e.canvasY,
42
+ pos[0] + size[0] - (BTN_SIZE + BTN_MARGIN[0]) * (i + 1),
43
+ pos[1] + BTN_MARGIN[1],
44
+ BTN_SIZE,
45
+ BTN_SIZE,
46
+ )
47
+ ) {
48
+ return toggle;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
56
+ * quick, single-click ability to mute/bypass.
57
+ */
58
+ app.registerExtension({
59
+ name: "rgthree.GroupHeaderToggles",
60
+ async setup() {
61
+
62
+ /**
63
+ * LiteGraph won't call `drawGroups` unless the canvas is dirty. Other nodes will do this, but
64
+ * in small workflows, we'll want to trigger it dirty so we can be drawn if we're in hover mode.
65
+ */
66
+ setInterval(() => {
67
+ if (
68
+ CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") &&
69
+ CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always"
70
+ ) {
71
+ app.canvas.setDirty(true, true);
72
+ }
73
+ }, 250);
74
+
75
+ /**
76
+ * Handles a click on the icon area if the user has the extension enable from settings.
77
+ * Hooks into the already overriden mouse down processor from rgthree.
78
+ */
79
+ rgthree.addEventListener("on-process-mouse-down", ((e: AdjustedMouseCustomEvent) => {
80
+ if (!CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled")) return;
81
+
82
+ const canvas = app.canvas as TLGraphCanvas;
83
+ if (canvas.selected_group) {
84
+ const originalEvent = e.detail.originalEvent;
85
+ const group = canvas.selected_group;
86
+ const clickedOnToggle = clickedOnToggleButton(originalEvent, group) || "";
87
+ const toggleAction = clickedOnToggle?.toLocaleUpperCase();
88
+ if (toggleAction) {
89
+ if (toggleAction === "QUEUE") {
90
+ const outputNodes = getOutputNodes(group._nodes);
91
+ if (!outputNodes?.length) {
92
+ rgthree.showMessage({
93
+ id: "no-output-in-group",
94
+ type: "warn",
95
+ timeout: 4000,
96
+ message: "No output nodes for group!",
97
+ });
98
+ } else {
99
+ rgthree.queueOutputNodes(outputNodes.map((n) => n.id));
100
+ }
101
+ } else {
102
+ const toggleMode = TOGGLE_TO_MODE.get(toggleAction);
103
+ if (toggleMode) {
104
+ group.recomputeInsideNodes();
105
+ const hasAnyActiveNodes = group._nodes.some((n) => n.mode === LiteGraph.ALWAYS);
106
+ const isAllMuted =
107
+ !hasAnyActiveNodes && group._nodes.every((n) => n.mode === LiteGraph.NEVER);
108
+ const isAllBypassed =
109
+ !hasAnyActiveNodes && !isAllMuted && group._nodes.every((n) => n.mode === 4);
110
+
111
+ let newMode: 0 | 1 | 2 | 3 | 4 = LiteGraph.ALWAYS;
112
+ if (toggleMode === LiteGraph.NEVER) {
113
+ newMode = isAllMuted ? LiteGraph.ALWAYS : LiteGraph.NEVER;
114
+ } else {
115
+ newMode = isAllBypassed ? LiteGraph.ALWAYS : 4;
116
+ }
117
+ for (const node of group._nodes) {
118
+ node.mode = newMode;
119
+ }
120
+ }
121
+ }
122
+ // Make it such that we're not then moving the group on drag.
123
+ canvas.selected_group = null;
124
+ canvas.dragging_canvas = false;
125
+ }
126
+ }
127
+ }) as EventListener);
128
+
129
+ /**
130
+ * Overrides LiteGraph's Canvas method for drawingGroups and, after calling the original, checks
131
+ * that the user has enabled fast toggles and draws them on the top-right of the app..
132
+ */
133
+ const drawGroups = LGraphCanvas.prototype.drawGroups;
134
+ LGraphCanvas.prototype.drawGroups = function (
135
+ canvasEl: HTMLCanvasElement,
136
+ ctx: CanvasRenderingContext2D,
137
+ ) {
138
+ drawGroups.apply(this, [...arguments] as any);
139
+
140
+ if (
141
+ !CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") ||
142
+ !rgthree.lastAdjustedMouseEvent
143
+ ) {
144
+ return;
145
+ }
146
+
147
+ const graph = app.graph as TLGraph;
148
+
149
+ let groups: TLGraphGroup[];
150
+ // Default to hover if not always.
151
+ if (CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always") {
152
+ const hoverGroup = graph.getGroupOnPos(
153
+ rgthree.lastAdjustedMouseEvent.canvasX,
154
+ rgthree.lastAdjustedMouseEvent.canvasY,
155
+ );
156
+ groups = hoverGroup ? [hoverGroup] : [];
157
+ } else {
158
+ groups = graph._groups || [];
159
+ }
160
+
161
+ if (!groups.length) {
162
+ return;
163
+ }
164
+
165
+ const toggles = getToggles();
166
+
167
+ ctx.save();
168
+ for (const group of groups || []) {
169
+ let anyActive = false;
170
+ let allMuted = !!group._nodes.length;
171
+ let allBypassed = allMuted;
172
+
173
+ // Find the current state of the group's nodes.
174
+ for (const node of group._nodes) {
175
+ anyActive = anyActive || node.mode === LiteGraph.ALWAYS;
176
+ allMuted = allMuted && node.mode === LiteGraph.NEVER;
177
+ allBypassed = allBypassed && node.mode === 4;
178
+ if (anyActive || (!allMuted && !allBypassed)) {
179
+ break;
180
+ }
181
+ }
182
+
183
+ // Display each toggle.
184
+ for (let i = 0; i < toggles.length; i++) {
185
+ const toggle = toggles[i];
186
+ const pos = group._pos;
187
+ const size = group._size;
188
+ ctx.fillStyle = ctx.strokeStyle = group.color || "#335";
189
+ const x = pos[0] + size[0] - BTN_MARGIN[0] - BTN_SIZE - (BTN_SPACING + BTN_SIZE) * i;
190
+ const y = pos[1] + BTN_MARGIN[1];
191
+ const midX = x + BTN_SIZE / 2;
192
+ const midY = y + BTN_SIZE / 2;
193
+ if (toggle === "queue") {
194
+ const outputNodes = getOutputNodes(group._nodes);
195
+ const oldGlobalAlpha = ctx.globalAlpha;
196
+ if (!outputNodes?.length) {
197
+ ctx.globalAlpha = 0.5;
198
+ }
199
+ ctx.lineJoin = "round";
200
+ ctx.lineCap = "round";
201
+ const arrowSizeX = BTN_SIZE * 0.6;
202
+ const arrowSizeY = BTN_SIZE * 0.7;
203
+ const arrow = new Path2D(
204
+ `M ${x + arrowSizeX / 2} ${midY} l 0 -${arrowSizeY / 2} l ${arrowSizeX} ${arrowSizeY / 2} l -${arrowSizeX} ${arrowSizeY / 2} z`,
205
+ );
206
+ ctx.stroke(arrow);
207
+ if (outputNodes?.length) {
208
+ ctx.fill(arrow);
209
+ }
210
+ ctx.globalAlpha = oldGlobalAlpha;
211
+ } else {
212
+ const on = toggle === "bypass" ? allBypassed : allMuted;
213
+
214
+ ctx.beginPath();
215
+ ctx.lineJoin = "round";
216
+ ctx.rect(x, y, BTN_SIZE, BTN_SIZE);
217
+
218
+ ctx.lineWidth = 2;
219
+ if (toggle === "mute") {
220
+ ctx.lineJoin = "round";
221
+ ctx.lineCap = "round";
222
+
223
+ if (on) {
224
+ ctx.stroke(
225
+ new Path2D(`
226
+ ${eyeFrame(midX, midY)}
227
+ ${eyeLashes(midX, midY)}
228
+ `),
229
+ );
230
+ } else {
231
+ const radius = BTN_GRID * 1.5;
232
+
233
+ // Eyeball fill
234
+ ctx.fill(
235
+ new Path2D(`
236
+ ${eyeFrame(midX, midY)}
237
+ ${eyeFrame(midX, midY, -1)}
238
+ ${circlePath(midX, midY, radius)}
239
+ ${circlePath(midX + BTN_GRID / 2, midY - BTN_GRID / 2, BTN_GRID * 0.375)}
240
+ `),
241
+ "evenodd",
242
+ );
243
+
244
+ // Eye Outline Stroke
245
+ ctx.stroke(new Path2D(`${eyeFrame(midX, midY)} ${eyeFrame(midX, midY, -1)}`));
246
+
247
+ // Eye lashes (faded)
248
+ ctx.globalAlpha = this.editor_alpha * 0.5;
249
+ ctx.stroke(new Path2D(`${eyeLashes(midX, midY)} ${eyeLashes(midX, midY, -1)}`));
250
+ ctx.globalAlpha = this.editor_alpha;
251
+ }
252
+ } else {
253
+ const lineChanges = on
254
+ ? `a ${BTN_GRID * 3}, ${BTN_GRID * 3} 0 1, 1 ${BTN_GRID * 3 * 2},0
255
+ l ${BTN_GRID * 2.0} 0`
256
+ : `l ${BTN_GRID * 8} 0`;
257
+
258
+ ctx.stroke(
259
+ new Path2D(`
260
+ M ${x} ${midY}
261
+ ${lineChanges}
262
+ M ${x + BTN_SIZE} ${midY} l -2 2
263
+ M ${x + BTN_SIZE} ${midY} l -2 -2
264
+ `),
265
+ );
266
+ ctx.fill(new Path2D(`${circlePath(x + BTN_GRID * 3, midY, BTN_GRID * 1.8)}`));
267
+ }
268
+ }
269
+ }
270
+ }
271
+ ctx.restore();
272
+ };
273
+ },
274
+ });
275
+
276
+ function eyeFrame(midX: number, midY: number, yFlip = 1) {
277
+ return `
278
+ M ${midX - BTN_SIZE / 2} ${midY}
279
+ c ${BTN_GRID * 1.5} ${yFlip * BTN_GRID * 2.5}, ${BTN_GRID * (8 - 1.5)} ${
280
+ yFlip * BTN_GRID * 2.5
281
+ }, ${BTN_GRID * 8} 0
282
+ `;
283
+ }
284
+
285
+ function eyeLashes(midX: number, midY: number, yFlip = 1) {
286
+ return `
287
+ M ${midX - BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l -1.15 ${1.25 * yFlip}
288
+ M ${midX - BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l -0.90 ${1.5 * yFlip}
289
+ M ${midX - BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l -0.50 ${1.75 * yFlip}
290
+ M ${midX + BTN_GRID * 0.0} ${midY + yFlip * BTN_GRID * 2.0} l 0.00 ${2.0 * yFlip}
291
+ M ${midX + BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l 0.50 ${1.75 * yFlip}
292
+ M ${midX + BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l 0.90 ${1.5 * yFlip}
293
+ M ${midX + BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l 1.15 ${1.25 * yFlip}
294
+ `;
295
+ }
296
+
297
+ function circlePath(cx: number, cy: number, radius: number) {
298
+ return `
299
+ M ${cx} ${cy}
300
+ m ${radius}, 0
301
+ a ${radius},${radius} 0 1, 1 -${radius * 2},0
302
+ a ${radius},${radius} 0 1, 1 ${radius * 2},0
303
+ `;
304
+ }
rgthree-comfy/src_web/comfyui/feature_import_individual_nodes.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { tryToGetWorkflowDataFromEvent } from "rgthree/common/utils_workflow.js";
2
+ import { app } from "scripts/app.js";
3
+ import type { ComfyNode, ComfyNodeConstructor, ComfyObjectInfo } from "typings/comfy.js";
4
+ import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
5
+
6
+ /**
7
+ * Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
8
+ * quick, single-click ability to mute/bypass.
9
+ */
10
+ app.registerExtension({
11
+ name: "rgthree.ImportIndividualNodes",
12
+ async beforeRegisterNodeDef(nodeType: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
13
+ const onDragOver = nodeType.prototype.onDragOver;
14
+ nodeType.prototype.onDragOver = function (e: DragEvent) {
15
+ let handled = onDragOver?.apply?.(this, [...arguments] as any);
16
+ if (handled != null) {
17
+ return handled;
18
+ }
19
+ return importIndividualNodesInnerOnDragOver(this, e);
20
+ };
21
+
22
+ const onDragDrop = nodeType.prototype.onDragDrop;
23
+ nodeType.prototype.onDragDrop = async function (e: DragEvent) {
24
+ const alreadyHandled = await onDragDrop?.apply?.(this, [...arguments] as any);
25
+ if (alreadyHandled) {
26
+ return alreadyHandled;
27
+ }
28
+ return importIndividualNodesInnerOnDragDrop(this, e);
29
+ };
30
+ },
31
+ });
32
+
33
+ export function importIndividualNodesInnerOnDragOver(node: ComfyNode, e: DragEvent): boolean {
34
+ return (
35
+ (node.widgets?.length && !!CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) ||
36
+ false
37
+ );
38
+ }
39
+
40
+ export async function importIndividualNodesInnerOnDragDrop(node: ComfyNode, e: DragEvent) {
41
+ if (!node.widgets?.length || !CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) {
42
+ return false;
43
+ }
44
+
45
+ let handled = false;
46
+ const { workflow, prompt } = await tryToGetWorkflowDataFromEvent(e);
47
+ if (!handled && workflow) {
48
+ const exact = (workflow.nodes || []).find((n) => n.id === node.id && n.type === node.type);
49
+ if (
50
+ exact?.widgets_values?.length &&
51
+ confirm(
52
+ "Found a node match from embedded workflow (same id & type) in this workflow. Would you like to set the widget values?",
53
+ )
54
+ ) {
55
+ node.configure({
56
+ // Title is overridden if it's not supplied; set it to the current then.
57
+ title: node.title,
58
+ widgets_values: [...(exact?.widgets_values || [])]
59
+ } as any);
60
+ handled = true;
61
+ }
62
+ }
63
+ if (!handled && workflow) {
64
+ handled = !confirm(
65
+ "No exact match found in workflow. Would you like to replace the whole workflow?",
66
+ );
67
+ }
68
+ return handled;
69
+ }
rgthree-comfy/src_web/comfyui/image_comparer.ts ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "scripts/app.js";
2
+ import { api } from "scripts/api.js";
3
+ import { RgthreeBaseServerNode } from "./base_node.js";
4
+ import { NodeTypesString } from "./constants.js";
5
+ import { ComfyNodeConstructor, ComfyObjectInfo } from "typings/comfy.js";
6
+ import {
7
+ AdjustedMouseEvent,
8
+ LGraphCanvas,
9
+ LGraphNode,
10
+ SerializedLGraphNode,
11
+ Vector2,
12
+ } from "typings/litegraph.js";
13
+ import { addConnectionLayoutSupport } from "./utils.js";
14
+ import {
15
+ RgthreeBaseHitAreas,
16
+ RgthreeBaseWidget,
17
+ RgthreeBaseWidgetBounds,
18
+ } from "./utils_widgets.js";
19
+ import { measureText } from "./utils_canvas.js";
20
+
21
+ type ComfyImageServerData = { filename: string; type: string; subfolder: string };
22
+ type ComfyImageData = { name: string; selected: boolean; url: string; img?: HTMLImageElement };
23
+ type OldExecutedPayload = {
24
+ images: ComfyImageServerData[];
25
+ };
26
+ type ExecutedPayload = {
27
+ a_images?: ComfyImageServerData[];
28
+ b_images?: ComfyImageServerData[];
29
+ };
30
+
31
+ function imageDataToUrl(data: ComfyImageServerData) {
32
+ return api.apiURL(
33
+ `/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}&subfolder=${
34
+ data.subfolder
35
+ }${app.getPreviewFormatParam()}${app.getRandParam()}`,
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Compares two images in one canvas node.
41
+ */
42
+ export class RgthreeImageComparer extends RgthreeBaseServerNode {
43
+ static override title = NodeTypesString.IMAGE_COMPARER;
44
+ static override type = NodeTypesString.IMAGE_COMPARER;
45
+ static comfyClass = NodeTypesString.IMAGE_COMPARER;
46
+
47
+ // These is what the core preview image node uses to show the context menu. May not be that helpful
48
+ // since it likely will always be "0" when a context menu is invoked without manually changing
49
+ // something.
50
+ imageIndex: number = 0;
51
+ imgs: InstanceType<typeof Image>[] = [];
52
+
53
+ override serialize_widgets = true;
54
+
55
+ isPointerDown = false;
56
+ isPointerOver = false;
57
+ pointerOverPos: Vector2 = [0, 0];
58
+
59
+ private canvasWidget: RgthreeImageComparerWidget | null = null;
60
+
61
+ static "@comparer_mode" = {
62
+ type: "combo",
63
+ values: ["Slide", "Click"],
64
+ };
65
+
66
+ constructor(title = RgthreeImageComparer.title) {
67
+ super(title);
68
+ this.properties["comparer_mode"] = "Slide";
69
+ }
70
+
71
+ override onExecuted(output: ExecutedPayload | OldExecutedPayload) {
72
+ super.onExecuted?.(output);
73
+ if ("images" in output) {
74
+ this.canvasWidget!.value = {
75
+ images: (output.images || []).map((d, i) => {
76
+ return {
77
+ name: i === 0 ? "A" : "B",
78
+ selected: true,
79
+ url: imageDataToUrl(d),
80
+ };
81
+ }),
82
+ };
83
+ } else {
84
+ output.a_images = output.a_images || [];
85
+ output.b_images = output.b_images || [];
86
+ const imagesToChoose: ComfyImageData[] = [];
87
+ const multiple = output.a_images.length + output.b_images.length > 2;
88
+ for (const [i, d] of output.a_images.entries()) {
89
+ imagesToChoose.push({
90
+ name: output.a_images.length > 1 || multiple ? `A${i + 1}` : "A",
91
+ selected: i === 0,
92
+ url: imageDataToUrl(d),
93
+ });
94
+ }
95
+ for (const [i, d] of output.b_images.entries()) {
96
+ imagesToChoose.push({
97
+ name: output.b_images.length > 1 || multiple ? `B${i + 1}` : "B",
98
+ selected: i === 0,
99
+ url: imageDataToUrl(d),
100
+ });
101
+ }
102
+ this.canvasWidget!.value = { images: imagesToChoose };
103
+ }
104
+ }
105
+
106
+ override onSerialize(o: SerializedLGraphNode) {
107
+ super.onSerialize && super.onSerialize(o);
108
+ for (let [index, widget_value] of (o.widgets_values || []).entries()) {
109
+ if (this.widgets[index]?.name === "rgthree_comparer") {
110
+ o.widgets_values![index] = (
111
+ this.widgets[index] as RgthreeImageComparerWidget
112
+ ).value.images.map((d) => {
113
+ d = { ...d };
114
+ delete d.img;
115
+ return d;
116
+ });
117
+ }
118
+ }
119
+ }
120
+
121
+ override onNodeCreated() {
122
+ this.canvasWidget = this.addCustomWidget(
123
+ new RgthreeImageComparerWidget("rgthree_comparer", this),
124
+ );
125
+ this.setSize(this.computeSize());
126
+ this.setDirtyCanvas(true, true);
127
+ }
128
+
129
+ /**
130
+ * Sets mouse as down or up based on param. If it's down, we also loop to check pointer is still
131
+ * down. This is because LiteGraph doesn't fire `onMouseUp` every time there's a mouse up, so we
132
+ * need to manually monitor `pointer_is_down` and, when it's no longer true, set mouse as up here.
133
+ */
134
+ private setIsPointerDown(down: boolean = this.isPointerDown) {
135
+ const newIsDown = down && !!app.canvas.pointer_is_down;
136
+ if (this.isPointerDown !== newIsDown) {
137
+ this.isPointerDown = newIsDown;
138
+ this.setDirtyCanvas(true, false);
139
+ }
140
+ this.imageIndex = this.isPointerDown ? 1 : 0;
141
+ if (this.isPointerDown) {
142
+ requestAnimationFrame(() => {
143
+ this.setIsPointerDown();
144
+ });
145
+ }
146
+ }
147
+
148
+ override onMouseDown(event: MouseEvent, pos: Vector2, graphCanvas: LGraphCanvas): void {
149
+ super.onMouseDown?.(event, pos, graphCanvas);
150
+ this.setIsPointerDown(true);
151
+ }
152
+
153
+ override onMouseEnter(event: MouseEvent, pos: Vector2, graphCanvas: LGraphCanvas): void {
154
+ super.onMouseEnter?.(event, pos, graphCanvas);
155
+ this.setIsPointerDown(!!app.canvas.pointer_is_down);
156
+ this.isPointerOver = true;
157
+ }
158
+
159
+ override onMouseLeave(event: MouseEvent, pos: Vector2, graphCanvas: LGraphCanvas): void {
160
+ super.onMouseLeave?.(event, pos, graphCanvas);
161
+ this.setIsPointerDown(false);
162
+ this.isPointerOver = false;
163
+ }
164
+
165
+ override onMouseMove(event: MouseEvent, pos: Vector2, graphCanvas: LGraphCanvas): void {
166
+ super.onMouseMove?.(event, pos, graphCanvas);
167
+ this.pointerOverPos = [...pos];
168
+ this.imageIndex = this.pointerOverPos[0] > this.size[0] / 2 ? 1 : 0;
169
+ }
170
+
171
+ override getHelp(): string {
172
+ return `
173
+ <p>
174
+ The ${this.type!.replace("(rgthree)", "")} node compares two images on top of each other.
175
+ </p>
176
+ <ul>
177
+ <li>
178
+ <p>
179
+ <strong>Notes</strong>
180
+ </p>
181
+ <ul>
182
+ <li><p>
183
+ The right-click menu may show image options (Open Image, Save Image, etc.) which will
184
+ correspond to the first image (image_a) if clicked on the left-half of the node, or
185
+ the second image if on the right half of the node.
186
+ </p></li>
187
+ </ul>
188
+ </li>
189
+ <li>
190
+ <p>
191
+ <strong>Inputs</strong>
192
+ </p>
193
+ <ul>
194
+ <li><p>
195
+ <code>image_a</code> <i>Optional.</i> The first image to use to compare.
196
+ image_a.
197
+ </p></li>
198
+ <li><p>
199
+ <code>image_b</code> <i>Optional.</i> The second image to use to compare.
200
+ </p></li>
201
+ <li><p>
202
+ <b>Note</b> <code>image_a</code> and <code>image_b</code> work best when a single
203
+ image is provided. However, if each/either are a batch, you can choose which item
204
+ from each batch are chosen to be compared. If either <code>image_a</code> or
205
+ <code>image_b</code> are not provided, the node will choose the first two from the
206
+ provided input if it's a batch, otherwise only show the single image (just as
207
+ Preview Image would).
208
+ </p></li>
209
+ </ul>
210
+ </li>
211
+ <li>
212
+ <p>
213
+ <strong>Properties.</strong> You can change the following properties (by right-clicking
214
+ on the node, and select "Properties" or "Properties Panel" from the menu):
215
+ </p>
216
+ <ul>
217
+ <li><p>
218
+ <code>comparer_mode</code> - Choose between "Slide" and "Click". Defaults to "Slide".
219
+ </p></li>
220
+ </ul>
221
+ </li>
222
+ </ul>`;
223
+ }
224
+
225
+ static override setUp(comfyClass: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
226
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeImageComparer);
227
+ }
228
+
229
+ static override onRegisteredForOverride(comfyClass: any) {
230
+ addConnectionLayoutSupport(RgthreeImageComparer, app, [
231
+ ["Left", "Right"],
232
+ ["Right", "Left"],
233
+ ]);
234
+ setTimeout(() => {
235
+ RgthreeImageComparer.category = comfyClass.category;
236
+ });
237
+ }
238
+ }
239
+
240
+ type RgthreeImageComparerWidgetValue = {
241
+ images: ComfyImageData[];
242
+ };
243
+
244
+ class RgthreeImageComparerWidget extends RgthreeBaseWidget<RgthreeImageComparerWidgetValue> {
245
+ private node: RgthreeImageComparer;
246
+
247
+ protected override hitAreas: RgthreeBaseHitAreas<any> = {
248
+ // We dynamically set this when/if we draw the labels.
249
+ };
250
+
251
+ private selected: [ComfyImageData?, ComfyImageData?] = [];
252
+
253
+ constructor(name: string, node: RgthreeImageComparer) {
254
+ super(name);
255
+ this.node = node;
256
+ }
257
+
258
+ private _value: RgthreeImageComparerWidgetValue = { images: [] };
259
+
260
+ set value(v: RgthreeImageComparerWidgetValue) {
261
+ // Despite `v` typed as RgthreeImageComparerWidgetValue, we may have gotten an array of strings
262
+ // from previous versions. We can handle that gracefully.
263
+ let cleanedVal;
264
+ if (Array.isArray(v)) {
265
+ cleanedVal = v.map((d, i) => {
266
+ if (!d || typeof d === "string") {
267
+ // We usually only have two here, so they're selected.
268
+ d = { url: d, name: i == 0 ? "A" : "B", selected: true };
269
+ }
270
+ return d;
271
+ });
272
+ } else {
273
+ cleanedVal = v.images || [];
274
+ }
275
+
276
+ // If we have multiple items in our sent value but we don't have both an "A" and a "B" then
277
+ // just simplify it down to the first two in the list.
278
+ if (cleanedVal.length > 2) {
279
+ const hasAAndB =
280
+ cleanedVal.some((i) => i.name.startsWith("A")) &&
281
+ cleanedVal.some((i) => i.name.startsWith("B"));
282
+ if (!hasAAndB) {
283
+ cleanedVal = [cleanedVal[0], cleanedVal[1]];
284
+ }
285
+ }
286
+
287
+ let selected = cleanedVal.filter((d) => d.selected);
288
+ // None are selected.
289
+ if (!selected.length && cleanedVal.length) {
290
+ cleanedVal[0]!.selected = true;
291
+ }
292
+
293
+ selected = cleanedVal.filter((d) => d.selected);
294
+ if (selected.length === 1 && cleanedVal.length > 1) {
295
+ cleanedVal.find((d) => !d.selected)!.selected = true;
296
+ }
297
+
298
+ this._value.images = cleanedVal;
299
+
300
+ selected = cleanedVal.filter((d) => d.selected);
301
+ this.setSelected(selected as [ComfyImageData, ComfyImageData]);
302
+ }
303
+
304
+ get value() {
305
+ return this._value;
306
+ }
307
+
308
+ setSelected(selected: [ComfyImageData, ComfyImageData]) {
309
+ this._value.images.forEach((d) => (d.selected = false));
310
+ this.node.imgs.length = 0;
311
+ for (const sel of selected) {
312
+ if (!sel.img) {
313
+ sel.img = new Image();
314
+ sel.img.src = sel.url;
315
+ this.node.imgs.push(sel.img);
316
+ }
317
+ sel.selected = true;
318
+ }
319
+ this.selected = selected;
320
+ }
321
+
322
+ draw(ctx: CanvasRenderingContext2D, node: RgthreeImageComparer, width: number, y: number) {
323
+ this.hitAreas = {};
324
+ if (this.value.images.length > 2) {
325
+ ctx.textAlign = "left";
326
+ ctx.textBaseline = "top";
327
+ ctx.font = `14px Arial`;
328
+ // Let's calculate the widths of all the labels.
329
+ const drawData: any = [];
330
+ const spacing = 5;
331
+ let x = 0;
332
+ for (const img of this.value.images) {
333
+ const width = measureText(ctx, img.name);
334
+ drawData.push({
335
+ img,
336
+ text: img.name,
337
+ x,
338
+ width: measureText(ctx, img.name),
339
+ });
340
+ x += width + spacing;
341
+ }
342
+ x = (node.size[0] - (x - spacing)) / 2;
343
+ for (const d of drawData) {
344
+ ctx.fillStyle = d.img.selected ? "rgba(180, 180, 180, 1)" : "rgba(180, 180, 180, 0.5)";
345
+ ctx.fillText(d.text, x, y);
346
+ this.hitAreas[d.text] = {
347
+ bounds: [x, y, d.width, 14],
348
+ data: d.img,
349
+ onDown: this.onSelectionDown,
350
+ };
351
+ x += d.width + spacing;
352
+ }
353
+ y += 20;
354
+ }
355
+
356
+ if (node.properties?.["comparer_mode"] === "Click") {
357
+ this.drawImage(ctx, this.selected[this.node.isPointerDown ? 1 : 0], y);
358
+ } else {
359
+ this.drawImage(ctx, this.selected[0], y);
360
+ if (node.isPointerOver) {
361
+ this.drawImage(ctx, this.selected[1], y, this.node.pointerOverPos[0]);
362
+ }
363
+ }
364
+ }
365
+
366
+ private onSelectionDown(
367
+ event: AdjustedMouseEvent,
368
+ pos: Vector2,
369
+ node: LGraphNode,
370
+ bounds?: RgthreeBaseWidgetBounds,
371
+ ) {
372
+ const selected = [...this.selected];
373
+ if (bounds?.data.name.startsWith("A")) {
374
+ selected[0] = bounds.data;
375
+ } else if (bounds?.data.name.startsWith("B")) {
376
+ selected[1] = bounds.data;
377
+ }
378
+ this.setSelected(selected as [ComfyImageData, ComfyImageData]);
379
+ }
380
+
381
+ private drawImage(
382
+ ctx: CanvasRenderingContext2D,
383
+ image: ComfyImageData | undefined,
384
+ y: number,
385
+ cropX?: number,
386
+ ) {
387
+ if (!image?.img?.naturalWidth || !image?.img?.naturalHeight) {
388
+ return;
389
+ }
390
+ let [nodeWidth, nodeHeight] = this.node.size;
391
+ const imageAspect = image?.img.naturalWidth / image?.img.naturalHeight;
392
+ let height = nodeHeight - y;
393
+ const widgetAspect = nodeWidth / height;
394
+ let targetWidth, targetHeight;
395
+ let offsetX = 0;
396
+ if (imageAspect > widgetAspect) {
397
+ targetWidth = nodeWidth;
398
+ targetHeight = nodeWidth / imageAspect;
399
+ } else {
400
+ targetHeight = height;
401
+ targetWidth = height * imageAspect;
402
+ offsetX = (nodeWidth - targetWidth) / 2;
403
+ }
404
+ const widthMultiplier = image?.img.naturalWidth / targetWidth;
405
+
406
+ const sourceX = 0;
407
+ const sourceY = 0;
408
+ const sourceWidth =
409
+ cropX != null ? (cropX - offsetX) * widthMultiplier : image?.img.naturalWidth;
410
+ const sourceHeight = image?.img.naturalHeight;
411
+ const destX = (nodeWidth - targetWidth) / 2;
412
+ const destY = y + (height - targetHeight) / 2;
413
+ const destWidth = cropX != null ? cropX - offsetX : targetWidth;
414
+ const destHeight = targetHeight;
415
+ ctx.save();
416
+ ctx.beginPath();
417
+ let globalCompositeOperation = ctx.globalCompositeOperation;
418
+ if (cropX) {
419
+ ctx.rect(destX, destY, destWidth, destHeight);
420
+ ctx.clip();
421
+ }
422
+ ctx.drawImage(
423
+ image?.img,
424
+ sourceX,
425
+ sourceY,
426
+ sourceWidth,
427
+ sourceHeight,
428
+ destX,
429
+ destY,
430
+ destWidth,
431
+ destHeight,
432
+ );
433
+ // Shows a label overlayed on the image. Not perfect, keeping commented out.
434
+ // ctx.globalCompositeOperation = "difference";
435
+ // ctx.fillStyle = "rgba(180, 180, 180, 1)";
436
+ // ctx.textAlign = "center";
437
+ // ctx.font = `32px Arial`;
438
+ // ctx.fillText(image.name, nodeWidth / 2, y + 32);
439
+ if (cropX != null && cropX >= (nodeWidth - targetWidth) / 2 && cropX <= targetWidth + offsetX) {
440
+ ctx.beginPath();
441
+ ctx.moveTo(cropX, destY);
442
+ ctx.lineTo(cropX, destY + destHeight);
443
+ ctx.globalCompositeOperation = "difference";
444
+ ctx.strokeStyle = "rgba(255,255,255, 1)";
445
+ ctx.stroke();
446
+ }
447
+ ctx.globalCompositeOperation = globalCompositeOperation;
448
+ ctx.restore();
449
+ }
450
+
451
+ computeSize(width: number): Vector2 {
452
+ return [width, 20];
453
+ }
454
+
455
+ serializeValue(serializedNode: SerializedLGraphNode, widgetIndex: number) {
456
+ const v = [];
457
+ for (const data of this._value.images) {
458
+ // Remove the img since it can't serialize.
459
+ const d = { ...data };
460
+ delete d.img;
461
+ v.push(d);
462
+ }
463
+ return { images: v };
464
+ }
465
+ }
466
+
467
+ app.registerExtension({
468
+ name: "rgthree.ImageComparer",
469
+ async beforeRegisterNodeDef(nodeType: ComfyNodeConstructor, nodeData: ComfyObjectInfo) {
470
+ if (nodeData.name === RgthreeImageComparer.type) {
471
+ RgthreeImageComparer.setUp(nodeType, nodeData);
472
+ }
473
+ },
474
+ });