Freak-ppa commited on
Commit
e26a977
1 Parent(s): 136fa84

Upload 71 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. ComfyUI/web/extensions/core/clipspace.js +166 -0
  2. ComfyUI/web/extensions/core/colorPalette.js +785 -0
  3. ComfyUI/web/extensions/core/contextMenuFilter.js +148 -0
  4. ComfyUI/web/extensions/core/dynamicPrompts.js +48 -0
  5. ComfyUI/web/extensions/core/editAttention.js +144 -0
  6. ComfyUI/web/extensions/core/groupNode.js +1281 -0
  7. ComfyUI/web/extensions/core/groupNodeManage.css +149 -0
  8. ComfyUI/web/extensions/core/groupNodeManage.js +422 -0
  9. ComfyUI/web/extensions/core/groupOptions.js +259 -0
  10. ComfyUI/web/extensions/core/invertMenuScrolling.js +36 -0
  11. ComfyUI/web/extensions/core/keybinds.js +69 -0
  12. ComfyUI/web/extensions/core/linkRenderMode.js +25 -0
  13. ComfyUI/web/extensions/core/maskeditor.js +967 -0
  14. ComfyUI/web/extensions/core/nodeTemplates.js +412 -0
  15. ComfyUI/web/extensions/core/noteNode.js +41 -0
  16. ComfyUI/web/extensions/core/rerouteNode.js +274 -0
  17. ComfyUI/web/extensions/core/saveImageExtraOutput.js +35 -0
  18. ComfyUI/web/extensions/core/simpleTouchSupport.js +102 -0
  19. ComfyUI/web/extensions/core/slotDefaults.js +91 -0
  20. ComfyUI/web/extensions/core/snapToGrid.js +171 -0
  21. ComfyUI/web/extensions/core/uploadAudio.js +186 -0
  22. ComfyUI/web/extensions/core/uploadImage.js +12 -0
  23. ComfyUI/web/extensions/core/webcamCapture.js +126 -0
  24. ComfyUI/web/extensions/core/widgetInputs.js +800 -0
  25. ComfyUI/web/extensions/logging.js.example +55 -0
  26. ComfyUI/web/fonts/materialdesignicons-webfont.woff2 +0 -0
  27. ComfyUI/web/index.html +49 -0
  28. ComfyUI/web/jsconfig.json +12 -0
  29. ComfyUI/web/lib/litegraph.core.js +0 -0
  30. ComfyUI/web/lib/litegraph.css +693 -0
  31. ComfyUI/web/lib/litegraph.extensions.js +21 -0
  32. ComfyUI/web/lib/materialdesignicons.min.css +0 -0
  33. ComfyUI/web/scripts/api.js +482 -0
  34. ComfyUI/web/scripts/app.js +2459 -0
  35. ComfyUI/web/scripts/changeTracker.js +255 -0
  36. ComfyUI/web/scripts/defaultGraph.js +119 -0
  37. ComfyUI/web/scripts/domWidget.js +329 -0
  38. ComfyUI/web/scripts/logging.js +370 -0
  39. ComfyUI/web/scripts/pnginfo.js +506 -0
  40. ComfyUI/web/scripts/ui.js +660 -0
  41. ComfyUI/web/scripts/ui/components/asyncDialog.js +64 -0
  42. ComfyUI/web/scripts/ui/components/button.js +163 -0
  43. ComfyUI/web/scripts/ui/components/buttonGroup.js +45 -0
  44. ComfyUI/web/scripts/ui/components/popup.js +128 -0
  45. ComfyUI/web/scripts/ui/components/splitButton.js +43 -0
  46. ComfyUI/web/scripts/ui/dialog.js +38 -0
  47. ComfyUI/web/scripts/ui/draggableList.js +287 -0
  48. ComfyUI/web/scripts/ui/imagePreview.js +97 -0
  49. ComfyUI/web/scripts/ui/menu/index.js +302 -0
  50. ComfyUI/web/scripts/ui/menu/interruptButton.js +23 -0
ComfyUI/web/extensions/core/clipspace.js ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { ComfyDialog, $el } from "../../scripts/ui.js";
3
+ import { ComfyApp } from "../../scripts/app.js";
4
+
5
+ export class ClipspaceDialog extends ComfyDialog {
6
+ static items = [];
7
+ static instance = null;
8
+
9
+ static registerButton(name, contextPredicate, callback) {
10
+ const item =
11
+ $el("button", {
12
+ type: "button",
13
+ textContent: name,
14
+ contextPredicate: contextPredicate,
15
+ onclick: callback
16
+ })
17
+
18
+ ClipspaceDialog.items.push(item);
19
+ }
20
+
21
+ static invalidatePreview() {
22
+ if(ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0) {
23
+ const img_preview = document.getElementById("clipspace_preview");
24
+ if(img_preview) {
25
+ img_preview.src = ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src;
26
+ img_preview.style.maxHeight = "100%";
27
+ img_preview.style.maxWidth = "100%";
28
+ }
29
+ }
30
+ }
31
+
32
+ static invalidate() {
33
+ if(ClipspaceDialog.instance) {
34
+ const self = ClipspaceDialog.instance;
35
+ // allow reconstruct controls when copying from non-image to image content.
36
+ const children = $el("div.comfy-modal-content", [ self.createImgSettings(), ...self.createButtons() ]);
37
+
38
+ if(self.element) {
39
+ // update
40
+ self.element.removeChild(self.element.firstChild);
41
+ self.element.appendChild(children);
42
+ }
43
+ else {
44
+ // new
45
+ self.element = $el("div.comfy-modal", { parent: document.body }, [children,]);
46
+ }
47
+
48
+ if(self.element.children[0].children.length <= 1) {
49
+ self.element.children[0].appendChild($el("p", {}, ["Unable to find the features to edit content of a format stored in the current Clipspace."]));
50
+ }
51
+
52
+ ClipspaceDialog.invalidatePreview();
53
+ }
54
+ }
55
+
56
+ constructor() {
57
+ super();
58
+ }
59
+
60
+ createButtons(self) {
61
+ const buttons = [];
62
+
63
+ for(let idx in ClipspaceDialog.items) {
64
+ const item = ClipspaceDialog.items[idx];
65
+ if(!item.contextPredicate || item.contextPredicate())
66
+ buttons.push(ClipspaceDialog.items[idx]);
67
+ }
68
+
69
+ buttons.push(
70
+ $el("button", {
71
+ type: "button",
72
+ textContent: "Close",
73
+ onclick: () => { this.close(); }
74
+ })
75
+ );
76
+
77
+ return buttons;
78
+ }
79
+
80
+ createImgSettings() {
81
+ if(ComfyApp.clipspace.imgs) {
82
+ const combo_items = [];
83
+ const imgs = ComfyApp.clipspace.imgs;
84
+
85
+ for(let i=0; i < imgs.length; i++) {
86
+ combo_items.push($el("option", {value:i}, [`${i}`]));
87
+ }
88
+
89
+ const combo1 = $el("select",
90
+ {id:"clipspace_img_selector", onchange:(event) => {
91
+ ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex;
92
+ ClipspaceDialog.invalidatePreview();
93
+ } }, combo_items);
94
+
95
+ const row1 =
96
+ $el("tr", {},
97
+ [
98
+ $el("td", {}, [$el("font", {color:"white"}, ["Select Image"])]),
99
+ $el("td", {}, [combo1])
100
+ ]);
101
+
102
+
103
+ const combo2 = $el("select",
104
+ {id:"clipspace_img_paste_mode", onchange:(event) => {
105
+ ComfyApp.clipspace['img_paste_mode'] = event.target.value;
106
+ } },
107
+ [
108
+ $el("option", {value:'selected'}, 'selected'),
109
+ $el("option", {value:'all'}, 'all')
110
+ ]);
111
+ combo2.value = ComfyApp.clipspace['img_paste_mode'];
112
+
113
+ const row2 =
114
+ $el("tr", {},
115
+ [
116
+ $el("td", {}, [$el("font", {color:"white"}, ["Paste Mode"])]),
117
+ $el("td", {}, [combo2])
118
+ ]);
119
+
120
+ const td = $el("td", {align:'center', width:'100px', height:'100px', colSpan:'2'},
121
+ [ $el("img",{id:"clipspace_preview", ondragstart:() => false},[]) ]);
122
+
123
+ const row3 =
124
+ $el("tr", {}, [td]);
125
+
126
+ return $el("table", {}, [row1, row2, row3]);
127
+ }
128
+ else {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ createImgPreview() {
134
+ if(ComfyApp.clipspace.imgs) {
135
+ return $el("img",{id:"clipspace_preview", ondragstart:() => false});
136
+ }
137
+ else
138
+ return [];
139
+ }
140
+
141
+ show() {
142
+ const img_preview = document.getElementById("clipspace_preview");
143
+ ClipspaceDialog.invalidate();
144
+
145
+ this.element.style.display = "block";
146
+ }
147
+ }
148
+
149
+ app.registerExtension({
150
+ name: "Comfy.Clipspace",
151
+ init(app) {
152
+ app.openClipspace =
153
+ function () {
154
+ if(!ClipspaceDialog.instance) {
155
+ ClipspaceDialog.instance = new ClipspaceDialog(app);
156
+ ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate;
157
+ }
158
+
159
+ if(ComfyApp.clipspace) {
160
+ ClipspaceDialog.instance.show();
161
+ }
162
+ else
163
+ app.ui.dialog.show("Clipspace is Empty!");
164
+ };
165
+ }
166
+ });
ComfyUI/web/extensions/core/colorPalette.js ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {app} from "../../scripts/app.js";
2
+ import {$el} from "../../scripts/ui.js";
3
+
4
+ // Manage color palettes
5
+
6
+ const colorPalettes = {
7
+ "dark": {
8
+ "id": "dark",
9
+ "name": "Dark (Default)",
10
+ "colors": {
11
+ "node_slot": {
12
+ "CLIP": "#FFD500", // bright yellow
13
+ "CLIP_VISION": "#A8DADC", // light blue-gray
14
+ "CLIP_VISION_OUTPUT": "#ad7452", // rusty brown-orange
15
+ "CONDITIONING": "#FFA931", // vibrant orange-yellow
16
+ "CONTROL_NET": "#6EE7B7", // soft mint green
17
+ "IMAGE": "#64B5F6", // bright sky blue
18
+ "LATENT": "#FF9CF9", // light pink-purple
19
+ "MASK": "#81C784", // muted green
20
+ "MODEL": "#B39DDB", // light lavender-purple
21
+ "STYLE_MODEL": "#C2FFAE", // light green-yellow
22
+ "VAE": "#FF6E6E", // bright red
23
+ "NOISE": "#B0B0B0", // gray
24
+ "GUIDER": "#66FFFF", // cyan
25
+ "SAMPLER": "#ECB4B4", // very soft red
26
+ "SIGMAS": "#CDFFCD", // soft lime green
27
+ "TAESD": "#DCC274", // cheesecake
28
+ },
29
+ "litegraph_base": {
30
+ "BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
31
+ "CLEAR_BACKGROUND_COLOR": "#222",
32
+ "NODE_TITLE_COLOR": "#999",
33
+ "NODE_SELECTED_TITLE_COLOR": "#FFF",
34
+ "NODE_TEXT_SIZE": 14,
35
+ "NODE_TEXT_COLOR": "#AAA",
36
+ "NODE_SUBTEXT_SIZE": 12,
37
+ "NODE_DEFAULT_COLOR": "#333",
38
+ "NODE_DEFAULT_BGCOLOR": "#353535",
39
+ "NODE_DEFAULT_BOXCOLOR": "#666",
40
+ "NODE_DEFAULT_SHAPE": "box",
41
+ "NODE_BOX_OUTLINE_COLOR": "#FFF",
42
+ "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
43
+ "DEFAULT_GROUP_FONT": 24,
44
+
45
+ "WIDGET_BGCOLOR": "#222",
46
+ "WIDGET_OUTLINE_COLOR": "#666",
47
+ "WIDGET_TEXT_COLOR": "#DDD",
48
+ "WIDGET_SECONDARY_TEXT_COLOR": "#999",
49
+
50
+ "LINK_COLOR": "#9A9",
51
+ "EVENT_LINK_COLOR": "#A86",
52
+ "CONNECTING_LINK_COLOR": "#AFA",
53
+ },
54
+ "comfy_base": {
55
+ "fg-color": "#fff",
56
+ "bg-color": "#202020",
57
+ "comfy-menu-bg": "#353535",
58
+ "comfy-input-bg": "#222",
59
+ "input-text": "#ddd",
60
+ "descrip-text": "#999",
61
+ "drag-text": "#ccc",
62
+ "error-text": "#ff4444",
63
+ "border-color": "#4e4e4e",
64
+ "tr-even-bg-color": "#222",
65
+ "tr-odd-bg-color": "#353535",
66
+ "content-bg": "#4e4e4e",
67
+ "content-fg": "#fff",
68
+ "content-hover-bg": "#222",
69
+ "content-hover-fg": "#fff"
70
+ }
71
+ },
72
+ },
73
+ "light": {
74
+ "id": "light",
75
+ "name": "Light",
76
+ "colors": {
77
+ "node_slot": {
78
+ "CLIP": "#FFA726", // orange
79
+ "CLIP_VISION": "#5C6BC0", // indigo
80
+ "CLIP_VISION_OUTPUT": "#8D6E63", // brown
81
+ "CONDITIONING": "#EF5350", // red
82
+ "CONTROL_NET": "#66BB6A", // green
83
+ "IMAGE": "#42A5F5", // blue
84
+ "LATENT": "#AB47BC", // purple
85
+ "MASK": "#9CCC65", // light green
86
+ "MODEL": "#7E57C2", // deep purple
87
+ "STYLE_MODEL": "#D4E157", // lime
88
+ "VAE": "#FF7043", // deep orange
89
+ },
90
+ "litegraph_base": {
91
+ "BACKGROUND_IMAGE": "data:image/gif;base64,R0lGODlhZABkALMAAAAAAP///+vr6+rq6ujo6Ofn5+bm5uXl5d3d3f///wAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAAkALAAAAABkAGQAAAT/UMhJq7046827HkcoHkYxjgZhnGG6si5LqnIM0/fL4qwwIMAg0CAsEovBIxKhRDaNy2GUOX0KfVFrssrNdpdaqTeKBX+dZ+jYvEaTf+y4W66mC8PUdrE879f9d2mBeoNLfH+IhYBbhIx2jkiHiomQlGKPl4uZe3CaeZifnnijgkESBqipqqusra6vsLGys62SlZO4t7qbuby7CLa+wqGWxL3Gv3jByMOkjc2lw8vOoNSi0czAncXW3Njdx9Pf48/Z4Kbbx+fQ5evZ4u3k1fKR6cn03vHlp7T9/v8A/8Gbp4+gwXoFryXMB2qgwoMMHyKEqA5fxX322FG8tzBcRnMW/zlulPbRncmQGidKjMjyYsOSKEF2FBlJQMCbOHP6c9iSZs+UnGYCdbnSo1CZI5F64kn0p1KnTH02nSoV3dGTV7FFHVqVq1dtWcMmVQZTbNGu72zqXMuW7danVL+6e4t1bEy6MeueBYLXrNO5Ze36jQtWsOG97wIj1vt3St/DjTEORss4nNq2mDP3e7w4r1bFkSET5hy6s2TRlD2/mSxXtSHQhCunXo26NevCpmvD/UU6tuullzULH76q92zdZG/Ltv1a+W+osI/nRmyc+fRi1Xdbh+68+0vv10dH3+77KD/i6IdnX669/frn5Zsjh4/2PXju8+8bzc9/6fj27LFnX11/+IUnXWl7BJfegm79FyB9JOl3oHgSklefgxAC+FmFGpqHIYcCfkhgfCohSKKJVo044YUMttggiBkmp6KFXw1oII24oYhjiDByaKOOHcp3Y5BD/njikSkO+eBREQAAOw==",
92
+ "CLEAR_BACKGROUND_COLOR": "lightgray",
93
+ "NODE_TITLE_COLOR": "#222",
94
+ "NODE_SELECTED_TITLE_COLOR": "#000",
95
+ "NODE_TEXT_SIZE": 14,
96
+ "NODE_TEXT_COLOR": "#444",
97
+ "NODE_SUBTEXT_SIZE": 12,
98
+ "NODE_DEFAULT_COLOR": "#F7F7F7",
99
+ "NODE_DEFAULT_BGCOLOR": "#F5F5F5",
100
+ "NODE_DEFAULT_BOXCOLOR": "#CCC",
101
+ "NODE_DEFAULT_SHAPE": "box",
102
+ "NODE_BOX_OUTLINE_COLOR": "#000",
103
+ "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)",
104
+ "DEFAULT_GROUP_FONT": 24,
105
+
106
+ "WIDGET_BGCOLOR": "#D4D4D4",
107
+ "WIDGET_OUTLINE_COLOR": "#999",
108
+ "WIDGET_TEXT_COLOR": "#222",
109
+ "WIDGET_SECONDARY_TEXT_COLOR": "#555",
110
+
111
+ "LINK_COLOR": "#4CAF50",
112
+ "EVENT_LINK_COLOR": "#FF9800",
113
+ "CONNECTING_LINK_COLOR": "#2196F3",
114
+ },
115
+ "comfy_base": {
116
+ "fg-color": "#222",
117
+ "bg-color": "#DDD",
118
+ "comfy-menu-bg": "#F5F5F5",
119
+ "comfy-input-bg": "#C9C9C9",
120
+ "input-text": "#222",
121
+ "descrip-text": "#444",
122
+ "drag-text": "#555",
123
+ "error-text": "#F44336",
124
+ "border-color": "#888",
125
+ "tr-even-bg-color": "#f9f9f9",
126
+ "tr-odd-bg-color": "#fff",
127
+ "content-bg": "#e0e0e0",
128
+ "content-fg": "#222",
129
+ "content-hover-bg": "#adadad",
130
+ "content-hover-fg": "#222"
131
+ }
132
+ },
133
+ },
134
+ "solarized": {
135
+ "id": "solarized",
136
+ "name": "Solarized",
137
+ "colors": {
138
+ "node_slot": {
139
+ "CLIP": "#2AB7CA", // light blue
140
+ "CLIP_VISION": "#6c71c4", // blue violet
141
+ "CLIP_VISION_OUTPUT": "#859900", // olive green
142
+ "CONDITIONING": "#d33682", // magenta
143
+ "CONTROL_NET": "#d1ffd7", // light mint green
144
+ "IMAGE": "#5940bb", // deep blue violet
145
+ "LATENT": "#268bd2", // blue
146
+ "MASK": "#CCC9E7", // light purple-gray
147
+ "MODEL": "#dc322f", // red
148
+ "STYLE_MODEL": "#1a998a", // teal
149
+ "UPSCALE_MODEL": "#054A29", // dark green
150
+ "VAE": "#facfad", // light pink-orange
151
+ },
152
+ "litegraph_base": {
153
+ "NODE_TITLE_COLOR": "#fdf6e3", // Base3
154
+ "NODE_SELECTED_TITLE_COLOR": "#A9D400",
155
+ "NODE_TEXT_SIZE": 14,
156
+ "NODE_TEXT_COLOR": "#657b83", // Base00
157
+ "NODE_SUBTEXT_SIZE": 12,
158
+ "NODE_DEFAULT_COLOR": "#094656",
159
+ "NODE_DEFAULT_BGCOLOR": "#073642", // Base02
160
+ "NODE_DEFAULT_BOXCOLOR": "#839496", // Base0
161
+ "NODE_DEFAULT_SHAPE": "box",
162
+ "NODE_BOX_OUTLINE_COLOR": "#fdf6e3", // Base3
163
+ "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
164
+ "DEFAULT_GROUP_FONT": 24,
165
+
166
+ "WIDGET_BGCOLOR": "#002b36", // Base03
167
+ "WIDGET_OUTLINE_COLOR": "#839496", // Base0
168
+ "WIDGET_TEXT_COLOR": "#fdf6e3", // Base3
169
+ "WIDGET_SECONDARY_TEXT_COLOR": "#93a1a1", // Base1
170
+
171
+ "LINK_COLOR": "#2aa198", // Solarized Cyan
172
+ "EVENT_LINK_COLOR": "#268bd2", // Solarized Blue
173
+ "CONNECTING_LINK_COLOR": "#859900", // Solarized Green
174
+ },
175
+ "comfy_base": {
176
+ "fg-color": "#fdf6e3", // Base3
177
+ "bg-color": "#002b36", // Base03
178
+ "comfy-menu-bg": "#073642", // Base02
179
+ "comfy-input-bg": "#002b36", // Base03
180
+ "input-text": "#93a1a1", // Base1
181
+ "descrip-text": "#586e75", // Base01
182
+ "drag-text": "#839496", // Base0
183
+ "error-text": "#dc322f", // Solarized Red
184
+ "border-color": "#657b83", // Base00
185
+ "tr-even-bg-color": "#002b36",
186
+ "tr-odd-bg-color": "#073642",
187
+ "content-bg": "#657b83",
188
+ "content-fg": "#fdf6e3",
189
+ "content-hover-bg": "#002b36",
190
+ "content-hover-fg": "#fdf6e3"
191
+ }
192
+ },
193
+ },
194
+ "arc": {
195
+ "id": "arc",
196
+ "name": "Arc",
197
+ "colors": {
198
+ "node_slot": {
199
+ "BOOLEAN": "",
200
+ "CLIP": "#eacb8b",
201
+ "CLIP_VISION": "#A8DADC",
202
+ "CLIP_VISION_OUTPUT": "#ad7452",
203
+ "CONDITIONING": "#cf876f",
204
+ "CONTROL_NET": "#00d78d",
205
+ "CONTROL_NET_WEIGHTS": "",
206
+ "FLOAT": "",
207
+ "GLIGEN": "",
208
+ "IMAGE": "#80a1c0",
209
+ "IMAGEUPLOAD": "",
210
+ "INT": "",
211
+ "LATENT": "#b38ead",
212
+ "LATENT_KEYFRAME": "",
213
+ "MASK": "#a3bd8d",
214
+ "MODEL": "#8978a7",
215
+ "SAMPLER": "",
216
+ "SIGMAS": "",
217
+ "STRING": "",
218
+ "STYLE_MODEL": "#C2FFAE",
219
+ "T2I_ADAPTER_WEIGHTS": "",
220
+ "TAESD": "#DCC274",
221
+ "TIMESTEP_KEYFRAME": "",
222
+ "UPSCALE_MODEL": "",
223
+ "VAE": "#be616b"
224
+ },
225
+ "litegraph_base": {
226
+ "BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAAsTAAALEwEAmpwYAAABcklEQVR4nO3YMUoDARgF4RfxBqZI6/0vZqFn0MYtrLIQMFN8U6V4LAtD+Jm9XG/v30OGl2e/AP7yevz4+vx45nvgF/+QGITEICQGITEIiUFIjNNC3q43u3/YnRJyPOzeQ+0e220nhRzReC8e7R7bbdvl+Jal1Bs46jEIiUFIDEJiEBKDkBhKPbZT6qHdptRTu02p53DUYxASg5AYhMQgJAYhMZR6bKfUQ7tNqad2m1LP4ajHICQGITEIiUFIDEJiKPXYTqmHdptST+02pZ7DUY9BSAxCYhASg5AYhMRQ6rGdUg/tNqWe2m1KPYejHoOQGITEICQGITEIiaHUYzulHtptSj2125R6Dkc9BiExCIlBSAxCYhASQ6nHdko9tNuUemq3KfUcjnoMQmIQEoOQGITEICSGUo/tlHpotyn11G5T6jkc9RiExCAkBiExCIlBSAylHtsp9dBuU+qp3abUczjqMQiJQUgMQmIQEoOQGITE+AHFISNQrFTGuwAAAABJRU5ErkJggg==",
227
+ "CLEAR_BACKGROUND_COLOR": "#2b2f38",
228
+ "NODE_TITLE_COLOR": "#b2b7bd",
229
+ "NODE_SELECTED_TITLE_COLOR": "#FFF",
230
+ "NODE_TEXT_SIZE": 14,
231
+ "NODE_TEXT_COLOR": "#AAA",
232
+ "NODE_SUBTEXT_SIZE": 12,
233
+ "NODE_DEFAULT_COLOR": "#2b2f38",
234
+ "NODE_DEFAULT_BGCOLOR": "#242730",
235
+ "NODE_DEFAULT_BOXCOLOR": "#6e7581",
236
+ "NODE_DEFAULT_SHAPE": "box",
237
+ "NODE_BOX_OUTLINE_COLOR": "#FFF",
238
+ "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
239
+ "DEFAULT_GROUP_FONT": 22,
240
+ "WIDGET_BGCOLOR": "#2b2f38",
241
+ "WIDGET_OUTLINE_COLOR": "#6e7581",
242
+ "WIDGET_TEXT_COLOR": "#DDD",
243
+ "WIDGET_SECONDARY_TEXT_COLOR": "#b2b7bd",
244
+ "LINK_COLOR": "#9A9",
245
+ "EVENT_LINK_COLOR": "#A86",
246
+ "CONNECTING_LINK_COLOR": "#AFA"
247
+ },
248
+ "comfy_base": {
249
+ "fg-color": "#fff",
250
+ "bg-color": "#2b2f38",
251
+ "comfy-menu-bg": "#242730",
252
+ "comfy-input-bg": "#2b2f38",
253
+ "input-text": "#ddd",
254
+ "descrip-text": "#b2b7bd",
255
+ "drag-text": "#ccc",
256
+ "error-text": "#ff4444",
257
+ "border-color": "#6e7581",
258
+ "tr-even-bg-color": "#2b2f38",
259
+ "tr-odd-bg-color": "#242730",
260
+ "content-bg": "#6e7581",
261
+ "content-fg": "#fff",
262
+ "content-hover-bg": "#2b2f38",
263
+ "content-hover-fg": "#fff"
264
+ }
265
+ },
266
+ },
267
+ "nord": {
268
+ "id": "nord",
269
+ "name": "Nord",
270
+ "colors": {
271
+ "node_slot": {
272
+ "BOOLEAN": "",
273
+ "CLIP": "#eacb8b",
274
+ "CLIP_VISION": "#A8DADC",
275
+ "CLIP_VISION_OUTPUT": "#ad7452",
276
+ "CONDITIONING": "#cf876f",
277
+ "CONTROL_NET": "#00d78d",
278
+ "CONTROL_NET_WEIGHTS": "",
279
+ "FLOAT": "",
280
+ "GLIGEN": "",
281
+ "IMAGE": "#80a1c0",
282
+ "IMAGEUPLOAD": "",
283
+ "INT": "",
284
+ "LATENT": "#b38ead",
285
+ "LATENT_KEYFRAME": "",
286
+ "MASK": "#a3bd8d",
287
+ "MODEL": "#8978a7",
288
+ "SAMPLER": "",
289
+ "SIGMAS": "",
290
+ "STRING": "",
291
+ "STYLE_MODEL": "#C2FFAE",
292
+ "T2I_ADAPTER_WEIGHTS": "",
293
+ "TAESD": "#DCC274",
294
+ "TIMESTEP_KEYFRAME": "",
295
+ "UPSCALE_MODEL": "",
296
+ "VAE": "#be616b"
297
+ },
298
+ "litegraph_base": {
299
+ "BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFu2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUwNDFhMmZjLTEzNzQtMTk0ZC1hZWY4LTYxMzM1MTVmNjUwMCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoyMzFiMTBiMC1iNGZiLTAyNGUtYjEyZS0zMDUzMDNjZDA3YzgiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoyMzFiMTBiMC1iNGZiLTAyNGUtYjEyZS0zMDUzMDNjZDA3YzgiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1MDQxYTJmYy0xMzc0LTE5NGQtYWVmOC02MTMzNTE1ZjY1MDAiIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz73jWg/AAAAyUlEQVR42u3WKwoAIBRFQRdiMb1idv9Lsxn9gEFw4Dbb8JCTojbbXEJwjJVL2HKwYMGCBQuWLbDmjr+9zrBGjHl1WVcvy2DBggULFizTWQpewSt4HzwsgwULFiwFr7MUvMtS8D54WLBgGSxYCl7BK3iXZbBgwYIFC5bpLAWv4BW8Dx6WwYIFC5aC11kK3mUpeB88LFiwDBYsBa/gFbzLMliwYMGCBct0loJX8AreBw/LYMGCBUvB6ywF77IUvA8eFixYBgsWrNfWAZPltufdad+1AAAAAElFTkSuQmCC",
300
+ "CLEAR_BACKGROUND_COLOR": "#212732",
301
+ "NODE_TITLE_COLOR": "#999",
302
+ "NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
303
+ "NODE_TEXT_SIZE": 14,
304
+ "NODE_TEXT_COLOR": "#bcc2c8",
305
+ "NODE_SUBTEXT_SIZE": 12,
306
+ "NODE_DEFAULT_COLOR": "#2e3440",
307
+ "NODE_DEFAULT_BGCOLOR": "#161b22",
308
+ "NODE_DEFAULT_BOXCOLOR": "#545d70",
309
+ "NODE_DEFAULT_SHAPE": "box",
310
+ "NODE_BOX_OUTLINE_COLOR": "#e5eaf0",
311
+ "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
312
+ "DEFAULT_GROUP_FONT": 24,
313
+ "WIDGET_BGCOLOR": "#2e3440",
314
+ "WIDGET_OUTLINE_COLOR": "#545d70",
315
+ "WIDGET_TEXT_COLOR": "#bcc2c8",
316
+ "WIDGET_SECONDARY_TEXT_COLOR": "#999",
317
+ "LINK_COLOR": "#9A9",
318
+ "EVENT_LINK_COLOR": "#A86",
319
+ "CONNECTING_LINK_COLOR": "#AFA"
320
+ },
321
+ "comfy_base": {
322
+ "fg-color": "#e5eaf0",
323
+ "bg-color": "#2e3440",
324
+ "comfy-menu-bg": "#161b22",
325
+ "comfy-input-bg": "#2e3440",
326
+ "input-text": "#bcc2c8",
327
+ "descrip-text": "#999",
328
+ "drag-text": "#ccc",
329
+ "error-text": "#ff4444",
330
+ "border-color": "#545d70",
331
+ "tr-even-bg-color": "#2e3440",
332
+ "tr-odd-bg-color": "#161b22",
333
+ "content-bg": "#545d70",
334
+ "content-fg": "#e5eaf0",
335
+ "content-hover-bg": "#2e3440",
336
+ "content-hover-fg": "#e5eaf0"
337
+ }
338
+ },
339
+ },
340
+ "github": {
341
+ "id": "github",
342
+ "name": "Github",
343
+ "colors": {
344
+ "node_slot": {
345
+ "BOOLEAN": "",
346
+ "CLIP": "#eacb8b",
347
+ "CLIP_VISION": "#A8DADC",
348
+ "CLIP_VISION_OUTPUT": "#ad7452",
349
+ "CONDITIONING": "#cf876f",
350
+ "CONTROL_NET": "#00d78d",
351
+ "CONTROL_NET_WEIGHTS": "",
352
+ "FLOAT": "",
353
+ "GLIGEN": "",
354
+ "IMAGE": "#80a1c0",
355
+ "IMAGEUPLOAD": "",
356
+ "INT": "",
357
+ "LATENT": "#b38ead",
358
+ "LATENT_KEYFRAME": "",
359
+ "MASK": "#a3bd8d",
360
+ "MODEL": "#8978a7",
361
+ "SAMPLER": "",
362
+ "SIGMAS": "",
363
+ "STRING": "",
364
+ "STYLE_MODEL": "#C2FFAE",
365
+ "T2I_ADAPTER_WEIGHTS": "",
366
+ "TAESD": "#DCC274",
367
+ "TIMESTEP_KEYFRAME": "",
368
+ "UPSCALE_MODEL": "",
369
+ "VAE": "#be616b"
370
+ },
371
+ "litegraph_base": {
372
+ "BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGlmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmIyYzRhNjA5LWJmYTctYTg0MC1iOGFlLTk3MzE2ZjM1ZGIyNyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjk0ZmNlZGU4LTE1MTctZmQ0MC04ZGU3LWYzOTgxM2E3ODk5ZiIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MjMxYjEwYjAtYjRmYi0wMjRlLWIxMmUtMzA1MzAzY2QwN2M4IiBzdEV2dDp3aGVuPSIyMDIzLTExLTEzVDAwOjE4OjAyKzAxOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjUuMSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjQ4OWY1NzlmLTJkNjUtZWQ0Zi04OTg0LTA4NGE2MGE1ZTMzNSIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xNVQwMjowNDo1OSswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMmM0YTYwOS1iZmE3LWE4NDAtYjhhZS05NzMxNmYzNWRiMjciIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4OTe6GAAAAx0lEQVR42u3WMQoAIQxFwRzJys77X8vSLiRgITif7bYbgrwYc/mKXyBoY4VVBgsWLFiwYFmOlTv+9jfDOjHmr8u6eVkGCxYsWLBgmc5S8ApewXvgYRksWLBgKXidpeBdloL3wMOCBctgwVLwCl7BuyyDBQsWLFiwTGcpeAWv4D3wsAwWLFiwFLzOUvAuS8F74GHBgmWwYCl4Ba/gXZbBggULFixYprMUvIJX8B54WAYLFixYCl5nKXiXpeA98LBgwTJYsGC9tg1o8f4TTtqzNQAAAABJRU5ErkJggg==",
373
+ "CLEAR_BACKGROUND_COLOR": "#040506",
374
+ "NODE_TITLE_COLOR": "#999",
375
+ "NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
376
+ "NODE_TEXT_SIZE": 14,
377
+ "NODE_TEXT_COLOR": "#bcc2c8",
378
+ "NODE_SUBTEXT_SIZE": 12,
379
+ "NODE_DEFAULT_COLOR": "#161b22",
380
+ "NODE_DEFAULT_BGCOLOR": "#13171d",
381
+ "NODE_DEFAULT_BOXCOLOR": "#30363d",
382
+ "NODE_DEFAULT_SHAPE": "box",
383
+ "NODE_BOX_OUTLINE_COLOR": "#e5eaf0",
384
+ "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
385
+ "DEFAULT_GROUP_FONT": 24,
386
+ "WIDGET_BGCOLOR": "#161b22",
387
+ "WIDGET_OUTLINE_COLOR": "#30363d",
388
+ "WIDGET_TEXT_COLOR": "#bcc2c8",
389
+ "WIDGET_SECONDARY_TEXT_COLOR": "#999",
390
+ "LINK_COLOR": "#9A9",
391
+ "EVENT_LINK_COLOR": "#A86",
392
+ "CONNECTING_LINK_COLOR": "#AFA"
393
+ },
394
+ "comfy_base": {
395
+ "fg-color": "#e5eaf0",
396
+ "bg-color": "#161b22",
397
+ "comfy-menu-bg": "#13171d",
398
+ "comfy-input-bg": "#161b22",
399
+ "input-text": "#bcc2c8",
400
+ "descrip-text": "#999",
401
+ "drag-text": "#ccc",
402
+ "error-text": "#ff4444",
403
+ "border-color": "#30363d",
404
+ "tr-even-bg-color": "#161b22",
405
+ "tr-odd-bg-color": "#13171d",
406
+ "content-bg": "#30363d",
407
+ "content-fg": "#e5eaf0",
408
+ "content-hover-bg": "#161b22",
409
+ "content-hover-fg": "#e5eaf0"
410
+ }
411
+ },
412
+ }
413
+ };
414
+
415
+ const id = "Comfy.ColorPalette";
416
+ const idCustomColorPalettes = "Comfy.CustomColorPalettes";
417
+ const defaultColorPaletteId = "dark";
418
+ const els = {}
419
+ // const ctxMenu = LiteGraph.ContextMenu;
420
+ app.registerExtension({
421
+ name: id,
422
+ addCustomNodeDefs(node_defs) {
423
+ const sortObjectKeys = (unordered) => {
424
+ return Object.keys(unordered).sort().reduce((obj, key) => {
425
+ obj[key] = unordered[key];
426
+ return obj;
427
+ }, {});
428
+ };
429
+
430
+ function getSlotTypes() {
431
+ var types = [];
432
+
433
+ const defs = node_defs;
434
+ for (const nodeId in defs) {
435
+ const nodeData = defs[nodeId];
436
+
437
+ var inputs = nodeData["input"]["required"];
438
+ if (nodeData["input"]["optional"] !== undefined) {
439
+ inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
440
+ }
441
+
442
+ for (const inputName in inputs) {
443
+ const inputData = inputs[inputName];
444
+ const type = inputData[0];
445
+
446
+ if (!Array.isArray(type)) {
447
+ types.push(type);
448
+ }
449
+ }
450
+
451
+ for (const o in nodeData["output"]) {
452
+ const output = nodeData["output"][o];
453
+ types.push(output);
454
+ }
455
+ }
456
+
457
+ return types;
458
+ }
459
+
460
+ function completeColorPalette(colorPalette) {
461
+ var types = getSlotTypes();
462
+
463
+ for (const type of types) {
464
+ if (!colorPalette.colors.node_slot[type]) {
465
+ colorPalette.colors.node_slot[type] = "";
466
+ }
467
+ }
468
+
469
+ colorPalette.colors.node_slot = sortObjectKeys(colorPalette.colors.node_slot);
470
+
471
+ return colorPalette;
472
+ }
473
+
474
+ const getColorPaletteTemplate = async () => {
475
+ let colorPalette = {
476
+ "id": "my_color_palette_unique_id",
477
+ "name": "My Color Palette",
478
+ "colors": {
479
+ "node_slot": {},
480
+ "litegraph_base": {},
481
+ "comfy_base": {}
482
+ }
483
+ };
484
+
485
+ // Copy over missing keys from default color palette
486
+ const defaultColorPalette = colorPalettes[defaultColorPaletteId];
487
+ for (const key in defaultColorPalette.colors.litegraph_base) {
488
+ if (!colorPalette.colors.litegraph_base[key]) {
489
+ colorPalette.colors.litegraph_base[key] = "";
490
+ }
491
+ }
492
+ for (const key in defaultColorPalette.colors.comfy_base) {
493
+ if (!colorPalette.colors.comfy_base[key]) {
494
+ colorPalette.colors.comfy_base[key] = "";
495
+ }
496
+ }
497
+
498
+ return completeColorPalette(colorPalette);
499
+ };
500
+
501
+ const getCustomColorPalettes = () => {
502
+ return app.ui.settings.getSettingValue(idCustomColorPalettes, {});
503
+ };
504
+
505
+ const setCustomColorPalettes = (customColorPalettes) => {
506
+ return app.ui.settings.setSettingValue(idCustomColorPalettes, customColorPalettes);
507
+ };
508
+
509
+ const addCustomColorPalette = async (colorPalette) => {
510
+ if (typeof (colorPalette) !== "object") {
511
+ alert("Invalid color palette.");
512
+ return;
513
+ }
514
+
515
+ if (!colorPalette.id) {
516
+ alert("Color palette missing id.");
517
+ return;
518
+ }
519
+
520
+ if (!colorPalette.name) {
521
+ alert("Color palette missing name.");
522
+ return;
523
+ }
524
+
525
+ if (!colorPalette.colors) {
526
+ alert("Color palette missing colors.");
527
+ return;
528
+ }
529
+
530
+ if (colorPalette.colors.node_slot && typeof (colorPalette.colors.node_slot) !== "object") {
531
+ alert("Invalid color palette colors.node_slot.");
532
+ return;
533
+ }
534
+
535
+ const customColorPalettes = getCustomColorPalettes();
536
+ customColorPalettes[colorPalette.id] = colorPalette;
537
+ setCustomColorPalettes(customColorPalettes);
538
+
539
+ for (const option of els.select.childNodes) {
540
+ if (option.value === "custom_" + colorPalette.id) {
541
+ els.select.removeChild(option);
542
+ }
543
+ }
544
+
545
+ els.select.append($el("option", {
546
+ textContent: colorPalette.name + " (custom)",
547
+ value: "custom_" + colorPalette.id,
548
+ selected: true
549
+ }));
550
+
551
+ setColorPalette("custom_" + colorPalette.id);
552
+ await loadColorPalette(colorPalette);
553
+ };
554
+
555
+ const deleteCustomColorPalette = async (colorPaletteId) => {
556
+ const customColorPalettes = getCustomColorPalettes();
557
+ delete customColorPalettes[colorPaletteId];
558
+ setCustomColorPalettes(customColorPalettes);
559
+
560
+ for (const option of els.select.childNodes) {
561
+ if (option.value === defaultColorPaletteId) {
562
+ option.selected = true;
563
+ }
564
+
565
+ if (option.value === "custom_" + colorPaletteId) {
566
+ els.select.removeChild(option);
567
+ }
568
+ }
569
+
570
+ setColorPalette(defaultColorPaletteId);
571
+ await loadColorPalette(getColorPalette());
572
+ };
573
+
574
+ const loadColorPalette = async (colorPalette) => {
575
+ colorPalette = await completeColorPalette(colorPalette);
576
+ if (colorPalette.colors) {
577
+ // Sets the colors of node slots and links
578
+ if (colorPalette.colors.node_slot) {
579
+ Object.assign(app.canvas.default_connection_color_byType, colorPalette.colors.node_slot);
580
+ Object.assign(LGraphCanvas.link_type_colors, colorPalette.colors.node_slot);
581
+ }
582
+ // Sets the colors of the LiteGraph objects
583
+ if (colorPalette.colors.litegraph_base) {
584
+ // Everything updates correctly in the loop, except the Node Title and Link Color for some reason
585
+ app.canvas.node_title_color = colorPalette.colors.litegraph_base.NODE_TITLE_COLOR;
586
+ app.canvas.default_link_color = colorPalette.colors.litegraph_base.LINK_COLOR;
587
+
588
+ for (const key in colorPalette.colors.litegraph_base) {
589
+ if (colorPalette.colors.litegraph_base.hasOwnProperty(key) && LiteGraph.hasOwnProperty(key)) {
590
+ LiteGraph[key] = colorPalette.colors.litegraph_base[key];
591
+ }
592
+ }
593
+ }
594
+ // Sets the color of ComfyUI elements
595
+ if (colorPalette.colors.comfy_base) {
596
+ const rootStyle = document.documentElement.style;
597
+ for (const key in colorPalette.colors.comfy_base) {
598
+ rootStyle.setProperty('--' + key, colorPalette.colors.comfy_base[key]);
599
+ }
600
+ }
601
+ app.canvas.draw(true, true);
602
+ }
603
+ };
604
+
605
+ const getColorPalette = (colorPaletteId) => {
606
+ if (!colorPaletteId) {
607
+ colorPaletteId = app.ui.settings.getSettingValue(id, defaultColorPaletteId);
608
+ }
609
+
610
+ if (colorPaletteId.startsWith("custom_")) {
611
+ colorPaletteId = colorPaletteId.substr(7);
612
+ let customColorPalettes = getCustomColorPalettes();
613
+ if (customColorPalettes[colorPaletteId]) {
614
+ return customColorPalettes[colorPaletteId];
615
+ }
616
+ }
617
+
618
+ return colorPalettes[colorPaletteId];
619
+ };
620
+
621
+ const setColorPalette = (colorPaletteId) => {
622
+ app.ui.settings.setSettingValue(id, colorPaletteId);
623
+ };
624
+
625
+ const fileInput = $el("input", {
626
+ type: "file",
627
+ accept: ".json",
628
+ style: {display: "none"},
629
+ parent: document.body,
630
+ onchange: () => {
631
+ const file = fileInput.files[0];
632
+ if (file.type === "application/json" || file.name.endsWith(".json")) {
633
+ const reader = new FileReader();
634
+ reader.onload = async () => {
635
+ await addCustomColorPalette(JSON.parse(reader.result));
636
+ };
637
+ reader.readAsText(file);
638
+ }
639
+ },
640
+ });
641
+
642
+ app.ui.settings.addSetting({
643
+ id,
644
+ name: "Color Palette",
645
+ type: (name, setter, value) => {
646
+ const options = [
647
+ ...Object.values(colorPalettes).map(c=> $el("option", {
648
+ textContent: c.name,
649
+ value: c.id,
650
+ selected: c.id === value
651
+ })),
652
+ ...Object.values(getCustomColorPalettes()).map(c=>$el("option", {
653
+ textContent: `${c.name} (custom)`,
654
+ value: `custom_${c.id}`,
655
+ selected: `custom_${c.id}` === value
656
+ })) ,
657
+ ];
658
+
659
+ els.select = $el("select", {
660
+ style: {
661
+ marginBottom: "0.15rem",
662
+ width: "100%",
663
+ },
664
+ onchange: (e) => {
665
+ setter(e.target.value);
666
+ }
667
+ }, options)
668
+
669
+ return $el("tr", [
670
+ $el("td", [
671
+ $el("label", {
672
+ for: id.replaceAll(".", "-"),
673
+ textContent: "Color palette",
674
+ }),
675
+ ]),
676
+ $el("td", [
677
+ els.select,
678
+ $el("div", {
679
+ style: {
680
+ display: "grid",
681
+ gap: "4px",
682
+ gridAutoFlow: "column",
683
+ },
684
+ }, [
685
+ $el("input", {
686
+ type: "button",
687
+ value: "Export",
688
+ onclick: async () => {
689
+ const colorPaletteId = app.ui.settings.getSettingValue(id, defaultColorPaletteId);
690
+ const colorPalette = await completeColorPalette(getColorPalette(colorPaletteId));
691
+ const json = JSON.stringify(colorPalette, null, 2); // convert the data to a JSON string
692
+ const blob = new Blob([json], {type: "application/json"});
693
+ const url = URL.createObjectURL(blob);
694
+ const a = $el("a", {
695
+ href: url,
696
+ download: colorPaletteId + ".json",
697
+ style: {display: "none"},
698
+ parent: document.body,
699
+ });
700
+ a.click();
701
+ setTimeout(function () {
702
+ a.remove();
703
+ window.URL.revokeObjectURL(url);
704
+ }, 0);
705
+ },
706
+ }),
707
+ $el("input", {
708
+ type: "button",
709
+ value: "Import",
710
+ onclick: () => {
711
+ fileInput.click();
712
+ }
713
+ }),
714
+ $el("input", {
715
+ type: "button",
716
+ value: "Template",
717
+ onclick: async () => {
718
+ const colorPalette = await getColorPaletteTemplate();
719
+ const json = JSON.stringify(colorPalette, null, 2); // convert the data to a JSON string
720
+ const blob = new Blob([json], {type: "application/json"});
721
+ const url = URL.createObjectURL(blob);
722
+ const a = $el("a", {
723
+ href: url,
724
+ download: "color_palette.json",
725
+ style: {display: "none"},
726
+ parent: document.body,
727
+ });
728
+ a.click();
729
+ setTimeout(function () {
730
+ a.remove();
731
+ window.URL.revokeObjectURL(url);
732
+ }, 0);
733
+ }
734
+ }),
735
+ $el("input", {
736
+ type: "button",
737
+ value: "Delete",
738
+ onclick: async () => {
739
+ let colorPaletteId = app.ui.settings.getSettingValue(id, defaultColorPaletteId);
740
+
741
+ if (colorPalettes[colorPaletteId]) {
742
+ alert("You cannot delete a built-in color palette.");
743
+ return;
744
+ }
745
+
746
+ if (colorPaletteId.startsWith("custom_")) {
747
+ colorPaletteId = colorPaletteId.substr(7);
748
+ }
749
+
750
+ await deleteCustomColorPalette(colorPaletteId);
751
+ }
752
+ }),
753
+ ]),
754
+ ]),
755
+ ])
756
+ },
757
+ defaultValue: defaultColorPaletteId,
758
+ async onChange(value) {
759
+ if (!value) {
760
+ return;
761
+ }
762
+
763
+ let palette = colorPalettes[value];
764
+ if (palette) {
765
+ await loadColorPalette(palette);
766
+ } else if (value.startsWith("custom_")) {
767
+ value = value.substr(7);
768
+ let customColorPalettes = getCustomColorPalettes();
769
+ if (customColorPalettes[value]) {
770
+ palette = customColorPalettes[value];
771
+ await loadColorPalette(customColorPalettes[value]);
772
+ }
773
+ }
774
+
775
+ let {BACKGROUND_IMAGE, CLEAR_BACKGROUND_COLOR} = palette.colors.litegraph_base;
776
+ if (BACKGROUND_IMAGE === undefined || CLEAR_BACKGROUND_COLOR === undefined) {
777
+ const base = colorPalettes["dark"].colors.litegraph_base;
778
+ BACKGROUND_IMAGE = base.BACKGROUND_IMAGE;
779
+ CLEAR_BACKGROUND_COLOR = base.CLEAR_BACKGROUND_COLOR;
780
+ }
781
+ app.canvas.updateBackground(BACKGROUND_IMAGE, CLEAR_BACKGROUND_COLOR);
782
+ },
783
+ });
784
+ },
785
+ });
ComfyUI/web/extensions/core/contextMenuFilter.js ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {app} from "../../scripts/app.js";
2
+
3
+ // Adds filtering to combo context menus
4
+
5
+ const ext = {
6
+ name: "Comfy.ContextMenuFilter",
7
+ init() {
8
+ const ctxMenu = LiteGraph.ContextMenu;
9
+
10
+ LiteGraph.ContextMenu = function (values, options) {
11
+ const ctx = ctxMenu.call(this, values, options);
12
+
13
+ // If we are a dark menu (only used for combo boxes) then add a filter input
14
+ if (options?.className === "dark" && values?.length > 10) {
15
+ const filter = document.createElement("input");
16
+ filter.classList.add("comfy-context-menu-filter");
17
+ filter.placeholder = "Filter list";
18
+ this.root.prepend(filter);
19
+
20
+ const items = Array.from(this.root.querySelectorAll(".litemenu-entry"));
21
+ let displayedItems = [...items];
22
+ let itemCount = displayedItems.length;
23
+
24
+ // We must request an animation frame for the current node of the active canvas to update.
25
+ requestAnimationFrame(() => {
26
+ const currentNode = LGraphCanvas.active_canvas.current_node;
27
+ const clickedComboValue = currentNode.widgets
28
+ ?.filter(w => w.type === "combo" && w.options.values.length === values.length)
29
+ .find(w => w.options.values.every((v, i) => v === values[i]))
30
+ ?.value;
31
+
32
+ let selectedIndex = clickedComboValue ? values.findIndex(v => v === clickedComboValue) : 0;
33
+ if (selectedIndex < 0) {
34
+ selectedIndex = 0;
35
+ }
36
+ let selectedItem = displayedItems[selectedIndex];
37
+ updateSelected();
38
+
39
+ // Apply highlighting to the selected item
40
+ function updateSelected() {
41
+ selectedItem?.style.setProperty("background-color", "");
42
+ selectedItem?.style.setProperty("color", "");
43
+ selectedItem = displayedItems[selectedIndex];
44
+ selectedItem?.style.setProperty("background-color", "#ccc", "important");
45
+ selectedItem?.style.setProperty("color", "#000", "important");
46
+ }
47
+
48
+ const positionList = () => {
49
+ const rect = this.root.getBoundingClientRect();
50
+
51
+ // If the top is off-screen then shift the element with scaling applied
52
+ if (rect.top < 0) {
53
+ const scale = 1 - this.root.getBoundingClientRect().height / this.root.clientHeight;
54
+ const shift = (this.root.clientHeight * scale) / 2;
55
+ this.root.style.top = -shift + "px";
56
+ }
57
+ }
58
+
59
+ // Arrow up/down to select items
60
+ filter.addEventListener("keydown", (event) => {
61
+ switch (event.key) {
62
+ case "ArrowUp":
63
+ event.preventDefault();
64
+ if (selectedIndex === 0) {
65
+ selectedIndex = itemCount - 1;
66
+ } else {
67
+ selectedIndex--;
68
+ }
69
+ updateSelected();
70
+ break;
71
+ case "ArrowRight":
72
+ event.preventDefault();
73
+ selectedIndex = itemCount - 1;
74
+ updateSelected();
75
+ break;
76
+ case "ArrowDown":
77
+ event.preventDefault();
78
+ if (selectedIndex === itemCount - 1) {
79
+ selectedIndex = 0;
80
+ } else {
81
+ selectedIndex++;
82
+ }
83
+ updateSelected();
84
+ break;
85
+ case "ArrowLeft":
86
+ event.preventDefault();
87
+ selectedIndex = 0;
88
+ updateSelected();
89
+ break;
90
+ case "Enter":
91
+ selectedItem?.click();
92
+ break;
93
+ case "Escape":
94
+ this.close();
95
+ break;
96
+ }
97
+ });
98
+
99
+ filter.addEventListener("input", () => {
100
+ // Hide all items that don't match our filter
101
+ const term = filter.value.toLocaleLowerCase();
102
+ // When filtering, recompute which items are visible for arrow up/down and maintain selection.
103
+ displayedItems = items.filter(item => {
104
+ const isVisible = !term || item.textContent.toLocaleLowerCase().includes(term);
105
+ item.style.display = isVisible ? "block" : "none";
106
+ return isVisible;
107
+ });
108
+
109
+ selectedIndex = 0;
110
+ if (displayedItems.includes(selectedItem)) {
111
+ selectedIndex = displayedItems.findIndex(d => d === selectedItem);
112
+ }
113
+ itemCount = displayedItems.length;
114
+
115
+ updateSelected();
116
+
117
+ // If we have an event then we can try and position the list under the source
118
+ if (options.event) {
119
+ let top = options.event.clientY - 10;
120
+
121
+ const bodyRect = document.body.getBoundingClientRect();
122
+ const rootRect = this.root.getBoundingClientRect();
123
+ if (bodyRect.height && top > bodyRect.height - rootRect.height - 10) {
124
+ top = Math.max(0, bodyRect.height - rootRect.height - 10);
125
+ }
126
+
127
+ this.root.style.top = top + "px";
128
+ positionList();
129
+ }
130
+ });
131
+
132
+ requestAnimationFrame(() => {
133
+ // Focus the filter box when opening
134
+ filter.focus();
135
+
136
+ positionList();
137
+ });
138
+ })
139
+ }
140
+
141
+ return ctx;
142
+ };
143
+
144
+ LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
145
+ },
146
+ }
147
+
148
+ app.registerExtension(ext);
ComfyUI/web/extensions/core/dynamicPrompts.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Allows for simple dynamic prompt replacement
4
+ // Inputs in the format {a|b} will have a random value of a or b chosen when the prompt is queued.
5
+
6
+ /*
7
+ * Strips C-style line and block comments from a string
8
+ */
9
+ function stripComments(str) {
10
+ return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g,'');
11
+ }
12
+
13
+ app.registerExtension({
14
+ name: "Comfy.DynamicPrompts",
15
+ nodeCreated(node) {
16
+ if (node.widgets) {
17
+ // Locate dynamic prompt text widgets
18
+ // Include any widgets with dynamicPrompts set to true, and customtext
19
+ const widgets = node.widgets.filter(
20
+ (n) => n.dynamicPrompts
21
+ );
22
+ for (const widget of widgets) {
23
+ // Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
24
+ widget.serializeValue = (workflowNode, widgetIndex) => {
25
+ let prompt = stripComments(widget.value);
26
+ while (prompt.replace("\\{", "").includes("{") && prompt.replace("\\}", "").includes("}")) {
27
+ const startIndex = prompt.replace("\\{", "00").indexOf("{");
28
+ const endIndex = prompt.replace("\\}", "00").indexOf("}");
29
+
30
+ const optionsString = prompt.substring(startIndex + 1, endIndex);
31
+ const options = optionsString.split("|");
32
+
33
+ const randomIndex = Math.floor(Math.random() * options.length);
34
+ const randomOption = options[randomIndex];
35
+
36
+ prompt = prompt.substring(0, startIndex) + randomOption + prompt.substring(endIndex + 1);
37
+ }
38
+
39
+ // Overwrite the value in the serialized workflow pnginfo
40
+ if (workflowNode?.widgets_values)
41
+ workflowNode.widgets_values[widgetIndex] = prompt;
42
+
43
+ return prompt;
44
+ };
45
+ }
46
+ }
47
+ },
48
+ });
ComfyUI/web/extensions/core/editAttention.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.EditAttention",
7
+ init() {
8
+ const editAttentionDelta = app.ui.settings.addSetting({
9
+ id: "Comfy.EditAttention.Delta",
10
+ name: "Ctrl+up/down precision",
11
+ type: "slider",
12
+ attrs: {
13
+ min: 0.01,
14
+ max: 0.5,
15
+ step: 0.01,
16
+ },
17
+ defaultValue: 0.05,
18
+ });
19
+
20
+ function incrementWeight(weight, delta) {
21
+ const floatWeight = parseFloat(weight);
22
+ if (isNaN(floatWeight)) return weight;
23
+ const newWeight = floatWeight + delta;
24
+ if (newWeight < 0) return "0";
25
+ return String(Number(newWeight.toFixed(10)));
26
+ }
27
+
28
+ function findNearestEnclosure(text, cursorPos) {
29
+ let start = cursorPos, end = cursorPos;
30
+ let openCount = 0, closeCount = 0;
31
+
32
+ // Find opening parenthesis before cursor
33
+ while (start >= 0) {
34
+ start--;
35
+ if (text[start] === "(" && openCount === closeCount) break;
36
+ if (text[start] === "(") openCount++;
37
+ if (text[start] === ")") closeCount++;
38
+ }
39
+ if (start < 0) return false;
40
+
41
+ openCount = 0;
42
+ closeCount = 0;
43
+
44
+ // Find closing parenthesis after cursor
45
+ while (end < text.length) {
46
+ if (text[end] === ")" && openCount === closeCount) break;
47
+ if (text[end] === "(") openCount++;
48
+ if (text[end] === ")") closeCount++;
49
+ end++;
50
+ }
51
+ if (end === text.length) return false;
52
+
53
+ return { start: start + 1, end: end };
54
+ }
55
+
56
+ function addWeightToParentheses(text) {
57
+ const parenRegex = /^\((.*)\)$/;
58
+ const parenMatch = text.match(parenRegex);
59
+
60
+ const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/;
61
+ const floatMatch = text.match(floatRegex);
62
+
63
+ if (parenMatch && !floatMatch) {
64
+ return `(${parenMatch[1]}:1.0)`;
65
+ } else {
66
+ return text;
67
+ }
68
+ };
69
+
70
+ function editAttention(event) {
71
+ const inputField = event.composedPath()[0];
72
+ const delta = parseFloat(editAttentionDelta.value);
73
+
74
+ if (inputField.tagName !== "TEXTAREA") return;
75
+ if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
76
+ if (!event.ctrlKey && !event.metaKey) return;
77
+
78
+ event.preventDefault();
79
+
80
+ let start = inputField.selectionStart;
81
+ let end = inputField.selectionEnd;
82
+ let selectedText = inputField.value.substring(start, end);
83
+
84
+ // If there is no selection, attempt to find the nearest enclosure, or select the current word
85
+ if (!selectedText) {
86
+ const nearestEnclosure = findNearestEnclosure(inputField.value, start);
87
+ if (nearestEnclosure) {
88
+ start = nearestEnclosure.start;
89
+ end = nearestEnclosure.end;
90
+ selectedText = inputField.value.substring(start, end);
91
+ } else {
92
+ // Select the current word, find the start and end of the word
93
+ const delimiters = " .,\\/!?%^*;:{}=-_`~()\r\n\t";
94
+
95
+ while (!delimiters.includes(inputField.value[start - 1]) && start > 0) {
96
+ start--;
97
+ }
98
+
99
+ while (!delimiters.includes(inputField.value[end]) && end < inputField.value.length) {
100
+ end++;
101
+ }
102
+
103
+ selectedText = inputField.value.substring(start, end);
104
+ if (!selectedText) return;
105
+ }
106
+ }
107
+
108
+ // If the selection ends with a space, remove it
109
+ if (selectedText[selectedText.length - 1] === " ") {
110
+ selectedText = selectedText.substring(0, selectedText.length - 1);
111
+ end -= 1;
112
+ }
113
+
114
+ // If there are parentheses left and right of the selection, select them
115
+ if (inputField.value[start - 1] === "(" && inputField.value[end] === ")") {
116
+ start -= 1;
117
+ end += 1;
118
+ selectedText = inputField.value.substring(start, end);
119
+ }
120
+
121
+ // If the selection is not enclosed in parentheses, add them
122
+ if (selectedText[0] !== "(" || selectedText[selectedText.length - 1] !== ")") {
123
+ selectedText = `(${selectedText})`;
124
+ }
125
+
126
+ // If the selection does not have a weight, add a weight of 1.0
127
+ selectedText = addWeightToParentheses(selectedText);
128
+
129
+ // Increment the weight
130
+ const weightDelta = event.key === "ArrowUp" ? delta : -delta;
131
+ const updatedText = selectedText.replace(/\((.*):(\d+(?:\.\d+)?)\)/, (match, text, weight) => {
132
+ weight = incrementWeight(weight, weightDelta);
133
+ if (weight == 1) {
134
+ return text;
135
+ } else {
136
+ return `(${text}:${weight})`;
137
+ }
138
+ });
139
+
140
+ inputField.setRangeText(updatedText, start, end, "select");
141
+ }
142
+ window.addEventListener("keydown", editAttention);
143
+ },
144
+ });
ComfyUI/web/extensions/core/groupNode.js ADDED
@@ -0,0 +1,1281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { api } from "../../scripts/api.js";
3
+ import { mergeIfValid } from "./widgetInputs.js";
4
+ import { ManageGroupDialog } from "./groupNodeManage.js";
5
+
6
+ const GROUP = Symbol();
7
+
8
+ const Workflow = {
9
+ InUse: {
10
+ Free: 0,
11
+ Registered: 1,
12
+ InWorkflow: 2,
13
+ },
14
+ isInUseGroupNode(name) {
15
+ const id = `workflow/${name}`;
16
+ // Check if lready registered/in use in this workflow
17
+ if (app.graph.extra?.groupNodes?.[name]) {
18
+ if (app.graph._nodes.find((n) => n.type === id)) {
19
+ return Workflow.InUse.InWorkflow;
20
+ } else {
21
+ return Workflow.InUse.Registered;
22
+ }
23
+ }
24
+ return Workflow.InUse.Free;
25
+ },
26
+ storeGroupNode(name, data) {
27
+ let extra = app.graph.extra;
28
+ if (!extra) app.graph.extra = extra = {};
29
+ let groupNodes = extra.groupNodes;
30
+ if (!groupNodes) extra.groupNodes = groupNodes = {};
31
+ groupNodes[name] = data;
32
+ },
33
+ };
34
+
35
+ class GroupNodeBuilder {
36
+ constructor(nodes) {
37
+ this.nodes = nodes;
38
+ }
39
+
40
+ build() {
41
+ const name = this.getName();
42
+ if (!name) return;
43
+
44
+ // Sort the nodes so they are in execution order
45
+ // this allows for widgets to be in the correct order when reconstructing
46
+ this.sortNodes();
47
+
48
+ this.nodeData = this.getNodeData();
49
+ Workflow.storeGroupNode(name, this.nodeData);
50
+
51
+ return { name, nodeData: this.nodeData };
52
+ }
53
+
54
+ getName() {
55
+ const name = prompt("Enter group name");
56
+ if (!name) return;
57
+ const used = Workflow.isInUseGroupNode(name);
58
+ switch (used) {
59
+ case Workflow.InUse.InWorkflow:
60
+ alert(
61
+ "An in use group node with this name already exists embedded in this workflow, please remove any instances or use a new name."
62
+ );
63
+ return;
64
+ case Workflow.InUse.Registered:
65
+ if (!confirm("A group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?")) {
66
+ return;
67
+ }
68
+ break;
69
+ }
70
+ return name;
71
+ }
72
+
73
+ sortNodes() {
74
+ // Gets the builders nodes in graph execution order
75
+ const nodesInOrder = app.graph.computeExecutionOrder(false);
76
+ this.nodes = this.nodes
77
+ .map((node) => ({ index: nodesInOrder.indexOf(node), node }))
78
+ .sort((a, b) => a.index - b.index || a.node.id - b.node.id)
79
+ .map(({ node }) => node);
80
+ }
81
+
82
+ getNodeData() {
83
+ const storeLinkTypes = (config) => {
84
+ // Store link types for dynamically typed nodes e.g. reroutes
85
+ for (const link of config.links) {
86
+ const origin = app.graph.getNodeById(link[4]);
87
+ const type = origin.outputs[link[1]].type;
88
+ link.push(type);
89
+ }
90
+ };
91
+
92
+ const storeExternalLinks = (config) => {
93
+ // Store any external links to the group in the config so when rebuilding we add extra slots
94
+ config.external = [];
95
+ for (let i = 0; i < this.nodes.length; i++) {
96
+ const node = this.nodes[i];
97
+ if (!node.outputs?.length) continue;
98
+ for (let slot = 0; slot < node.outputs.length; slot++) {
99
+ let hasExternal = false;
100
+ const output = node.outputs[slot];
101
+ let type = output.type;
102
+ if (!output.links?.length) continue;
103
+ for (const l of output.links) {
104
+ const link = app.graph.links[l];
105
+ if (!link) continue;
106
+ if (type === "*") type = link.type;
107
+
108
+ if (!app.canvas.selected_nodes[link.target_id]) {
109
+ hasExternal = true;
110
+ break;
111
+ }
112
+ }
113
+ if (hasExternal) {
114
+ config.external.push([i, slot, type]);
115
+ }
116
+ }
117
+ }
118
+ };
119
+
120
+ // Use the built in copyToClipboard function to generate the node data we need
121
+ const backup = localStorage.getItem("litegrapheditor_clipboard");
122
+ try {
123
+ app.canvas.copyToClipboard(this.nodes);
124
+ const config = JSON.parse(localStorage.getItem("litegrapheditor_clipboard"));
125
+
126
+ storeLinkTypes(config);
127
+ storeExternalLinks(config);
128
+
129
+ return config;
130
+ } finally {
131
+ localStorage.setItem("litegrapheditor_clipboard", backup);
132
+ }
133
+ }
134
+ }
135
+
136
+ export class GroupNodeConfig {
137
+ constructor(name, nodeData) {
138
+ this.name = name;
139
+ this.nodeData = nodeData;
140
+ this.getLinks();
141
+
142
+ this.inputCount = 0;
143
+ this.oldToNewOutputMap = {};
144
+ this.newToOldOutputMap = {};
145
+ this.oldToNewInputMap = {};
146
+ this.oldToNewWidgetMap = {};
147
+ this.newToOldWidgetMap = {};
148
+ this.primitiveDefs = {};
149
+ this.widgetToPrimitive = {};
150
+ this.primitiveToWidget = {};
151
+ this.nodeInputs = {};
152
+ this.outputVisibility = [];
153
+ }
154
+
155
+ async registerType(source = "workflow") {
156
+ this.nodeDef = {
157
+ output: [],
158
+ output_name: [],
159
+ output_is_list: [],
160
+ output_is_hidden: [],
161
+ name: source + "/" + this.name,
162
+ display_name: this.name,
163
+ category: "group nodes" + ("/" + source),
164
+ input: { required: {} },
165
+
166
+ [GROUP]: this,
167
+ };
168
+
169
+ this.inputs = [];
170
+ const seenInputs = {};
171
+ const seenOutputs = {};
172
+ for (let i = 0; i < this.nodeData.nodes.length; i++) {
173
+ const node = this.nodeData.nodes[i];
174
+ node.index = i;
175
+ this.processNode(node, seenInputs, seenOutputs);
176
+ }
177
+
178
+ for (const p of this.#convertedToProcess) {
179
+ p();
180
+ }
181
+ this.#convertedToProcess = null;
182
+ await app.registerNodeDef("workflow/" + this.name, this.nodeDef);
183
+ }
184
+
185
+ getLinks() {
186
+ this.linksFrom = {};
187
+ this.linksTo = {};
188
+ this.externalFrom = {};
189
+
190
+ // Extract links for easy lookup
191
+ for (const l of this.nodeData.links) {
192
+ const [sourceNodeId, sourceNodeSlot, targetNodeId, targetNodeSlot] = l;
193
+
194
+ // Skip links outside the copy config
195
+ if (sourceNodeId == null) continue;
196
+
197
+ if (!this.linksFrom[sourceNodeId]) {
198
+ this.linksFrom[sourceNodeId] = {};
199
+ }
200
+ if (!this.linksFrom[sourceNodeId][sourceNodeSlot]) {
201
+ this.linksFrom[sourceNodeId][sourceNodeSlot] = [];
202
+ }
203
+ this.linksFrom[sourceNodeId][sourceNodeSlot].push(l);
204
+
205
+ if (!this.linksTo[targetNodeId]) {
206
+ this.linksTo[targetNodeId] = {};
207
+ }
208
+ this.linksTo[targetNodeId][targetNodeSlot] = l;
209
+ }
210
+
211
+ if (this.nodeData.external) {
212
+ for (const ext of this.nodeData.external) {
213
+ if (!this.externalFrom[ext[0]]) {
214
+ this.externalFrom[ext[0]] = { [ext[1]]: ext[2] };
215
+ } else {
216
+ this.externalFrom[ext[0]][ext[1]] = ext[2];
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ processNode(node, seenInputs, seenOutputs) {
223
+ const def = this.getNodeDef(node);
224
+ if (!def) return;
225
+
226
+ const inputs = { ...def.input?.required, ...def.input?.optional };
227
+
228
+ this.inputs.push(this.processNodeInputs(node, seenInputs, inputs));
229
+ if (def.output?.length) this.processNodeOutputs(node, seenOutputs, def);
230
+ }
231
+
232
+ getNodeDef(node) {
233
+ const def = globalDefs[node.type];
234
+ if (def) return def;
235
+
236
+ const linksFrom = this.linksFrom[node.index];
237
+ if (node.type === "PrimitiveNode") {
238
+ // Skip as its not linked
239
+ if (!linksFrom) return;
240
+
241
+ let type = linksFrom["0"][0][5];
242
+ if (type === "COMBO") {
243
+ // Use the array items
244
+ const source = node.outputs[0].widget.name;
245
+ const fromTypeName = this.nodeData.nodes[linksFrom["0"][0][2]].type;
246
+ const fromType = globalDefs[fromTypeName];
247
+ const input = fromType.input.required[source] ?? fromType.input.optional[source];
248
+ type = input[0];
249
+ }
250
+
251
+ const def = (this.primitiveDefs[node.index] = {
252
+ input: {
253
+ required: {
254
+ value: [type, {}],
255
+ },
256
+ },
257
+ output: [type],
258
+ output_name: [],
259
+ output_is_list: [],
260
+ });
261
+ return def;
262
+ } else if (node.type === "Reroute") {
263
+ const linksTo = this.linksTo[node.index];
264
+ if (linksTo && linksFrom && !this.externalFrom[node.index]?.[0]) {
265
+ // Being used internally
266
+ return null;
267
+ }
268
+
269
+ let config = {};
270
+ let rerouteType = "*";
271
+ if (linksFrom) {
272
+ for (const [, , id, slot] of linksFrom["0"]) {
273
+ const node = this.nodeData.nodes[id];
274
+ const input = node.inputs[slot];
275
+ if (rerouteType === "*") {
276
+ rerouteType = input.type;
277
+ }
278
+ if (input.widget) {
279
+ const targetDef = globalDefs[node.type];
280
+ const targetWidget = targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name];
281
+
282
+ const widget = [targetWidget[0], config];
283
+ const res = mergeIfValid(
284
+ {
285
+ widget,
286
+ },
287
+ targetWidget,
288
+ false,
289
+ null,
290
+ widget
291
+ );
292
+ config = res?.customConfig ?? config;
293
+ }
294
+ }
295
+ } else if (linksTo) {
296
+ const [id, slot] = linksTo["0"];
297
+ rerouteType = this.nodeData.nodes[id].outputs[slot].type;
298
+ } else {
299
+ // Reroute used as a pipe
300
+ for (const l of this.nodeData.links) {
301
+ if (l[2] === node.index) {
302
+ rerouteType = l[5];
303
+ break;
304
+ }
305
+ }
306
+ if (rerouteType === "*") {
307
+ // Check for an external link
308
+ const t = this.externalFrom[node.index]?.[0];
309
+ if (t) {
310
+ rerouteType = t;
311
+ }
312
+ }
313
+ }
314
+
315
+ config.forceInput = true;
316
+ return {
317
+ input: {
318
+ required: {
319
+ [rerouteType]: [rerouteType, config],
320
+ },
321
+ },
322
+ output: [rerouteType],
323
+ output_name: [],
324
+ output_is_list: [],
325
+ };
326
+ }
327
+
328
+ console.warn("Skipping virtual node " + node.type + " when building group node " + this.name);
329
+ }
330
+
331
+ getInputConfig(node, inputName, seenInputs, config, extra) {
332
+ const customConfig = this.nodeData.config?.[node.index]?.input?.[inputName];
333
+ let name = customConfig?.name ?? node.inputs?.find((inp) => inp.name === inputName)?.label ?? inputName;
334
+ let key = name;
335
+ let prefix = "";
336
+ // Special handling for primitive to include the title if it is set rather than just "value"
337
+ if ((node.type === "PrimitiveNode" && node.title) || name in seenInputs) {
338
+ prefix = `${node.title ?? node.type} `;
339
+ key = name = `${prefix}${inputName}`;
340
+ if (name in seenInputs) {
341
+ name = `${prefix}${seenInputs[name]} ${inputName}`;
342
+ }
343
+ }
344
+ seenInputs[key] = (seenInputs[key] ?? 1) + 1;
345
+
346
+ if (inputName === "seed" || inputName === "noise_seed") {
347
+ if (!extra) extra = {};
348
+ extra.control_after_generate = `${prefix}control_after_generate`;
349
+ }
350
+ if (config[0] === "IMAGEUPLOAD") {
351
+ if (!extra) extra = {};
352
+ extra.widget = this.oldToNewWidgetMap[node.index]?.[config[1]?.widget ?? "image"] ?? "image";
353
+ }
354
+
355
+ if (extra) {
356
+ config = [config[0], { ...config[1], ...extra }];
357
+ }
358
+
359
+ return { name, config, customConfig };
360
+ }
361
+
362
+ processWidgetInputs(inputs, node, inputNames, seenInputs) {
363
+ const slots = [];
364
+ const converted = new Map();
365
+ const widgetMap = (this.oldToNewWidgetMap[node.index] = {});
366
+ for (const inputName of inputNames) {
367
+ let widgetType = app.getWidgetType(inputs[inputName], inputName);
368
+ if (widgetType) {
369
+ const convertedIndex = node.inputs?.findIndex((inp) => inp.name === inputName && inp.widget?.name === inputName);
370
+ if (convertedIndex > -1) {
371
+ // This widget has been converted to a widget
372
+ // We need to store this in the correct position so link ids line up
373
+ converted.set(convertedIndex, inputName);
374
+ widgetMap[inputName] = null;
375
+ } else {
376
+ // Normal widget
377
+ const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
378
+ this.nodeDef.input.required[name] = config;
379
+ widgetMap[inputName] = name;
380
+ this.newToOldWidgetMap[name] = { node, inputName };
381
+ }
382
+ } else {
383
+ // Normal input
384
+ slots.push(inputName);
385
+ }
386
+ }
387
+ return { converted, slots };
388
+ }
389
+
390
+ checkPrimitiveConnection(link, inputName, inputs) {
391
+ const sourceNode = this.nodeData.nodes[link[0]];
392
+ if (sourceNode.type === "PrimitiveNode") {
393
+ // Merge link configurations
394
+ const [sourceNodeId, _, targetNodeId, __] = link;
395
+ const primitiveDef = this.primitiveDefs[sourceNodeId];
396
+ const targetWidget = inputs[inputName];
397
+ const primitiveConfig = primitiveDef.input.required.value;
398
+ const output = { widget: primitiveConfig };
399
+ const config = mergeIfValid(output, targetWidget, false, null, primitiveConfig);
400
+ primitiveConfig[1] = config?.customConfig ?? inputs[inputName][1] ? { ...inputs[inputName][1] } : {};
401
+
402
+ let name = this.oldToNewWidgetMap[sourceNodeId]["value"];
403
+ name = name.substr(0, name.length - 6);
404
+ primitiveConfig[1].control_after_generate = true;
405
+ primitiveConfig[1].control_prefix = name;
406
+
407
+ let toPrimitive = this.widgetToPrimitive[targetNodeId];
408
+ if (!toPrimitive) {
409
+ toPrimitive = this.widgetToPrimitive[targetNodeId] = {};
410
+ }
411
+ if (toPrimitive[inputName]) {
412
+ toPrimitive[inputName].push(sourceNodeId);
413
+ }
414
+ toPrimitive[inputName] = sourceNodeId;
415
+
416
+ let toWidget = this.primitiveToWidget[sourceNodeId];
417
+ if (!toWidget) {
418
+ toWidget = this.primitiveToWidget[sourceNodeId] = [];
419
+ }
420
+ toWidget.push({ nodeId: targetNodeId, inputName });
421
+ }
422
+ }
423
+
424
+ processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
425
+ this.nodeInputs[node.index] = {};
426
+ for (let i = 0; i < slots.length; i++) {
427
+ const inputName = slots[i];
428
+ if (linksTo[i]) {
429
+ this.checkPrimitiveConnection(linksTo[i], inputName, inputs);
430
+ // This input is linked so we can skip it
431
+ continue;
432
+ }
433
+
434
+ const { name, config, customConfig } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
435
+
436
+ this.nodeInputs[node.index][inputName] = name;
437
+ if(customConfig?.visible === false) continue;
438
+
439
+ this.nodeDef.input.required[name] = config;
440
+ inputMap[i] = this.inputCount++;
441
+ }
442
+ }
443
+
444
+ processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs) {
445
+ // Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up
446
+ const convertedSlots = [...converted.keys()].sort().map((k) => converted.get(k));
447
+ for (let i = 0; i < convertedSlots.length; i++) {
448
+ const inputName = convertedSlots[i];
449
+ if (linksTo[slots.length + i]) {
450
+ this.checkPrimitiveConnection(linksTo[slots.length + i], inputName, inputs);
451
+ // This input is linked so we can skip it
452
+ continue;
453
+ }
454
+
455
+ const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], {
456
+ defaultInput: true,
457
+ });
458
+
459
+ this.nodeDef.input.required[name] = config;
460
+ this.newToOldWidgetMap[name] = { node, inputName };
461
+
462
+ if (!this.oldToNewWidgetMap[node.index]) {
463
+ this.oldToNewWidgetMap[node.index] = {};
464
+ }
465
+ this.oldToNewWidgetMap[node.index][inputName] = name;
466
+
467
+ inputMap[slots.length + i] = this.inputCount++;
468
+ }
469
+ }
470
+
471
+ #convertedToProcess = [];
472
+ processNodeInputs(node, seenInputs, inputs) {
473
+ const inputMapping = [];
474
+
475
+ const inputNames = Object.keys(inputs);
476
+ if (!inputNames.length) return;
477
+
478
+ const { converted, slots } = this.processWidgetInputs(inputs, node, inputNames, seenInputs);
479
+ const linksTo = this.linksTo[node.index] ?? {};
480
+ const inputMap = (this.oldToNewInputMap[node.index] = {});
481
+ this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs);
482
+
483
+ // Converted inputs have to be processed after all other nodes as they'll be at the end of the list
484
+ this.#convertedToProcess.push(() => this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs));
485
+
486
+ return inputMapping;
487
+ }
488
+
489
+ processNodeOutputs(node, seenOutputs, def) {
490
+ const oldToNew = (this.oldToNewOutputMap[node.index] = {});
491
+
492
+ // Add outputs
493
+ for (let outputId = 0; outputId < def.output.length; outputId++) {
494
+ const linksFrom = this.linksFrom[node.index];
495
+ // If this output is linked internally we flag it to hide
496
+ const hasLink = linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId];
497
+ const customConfig = this.nodeData.config?.[node.index]?.output?.[outputId];
498
+ const visible = customConfig?.visible ?? !hasLink;
499
+ this.outputVisibility.push(visible);
500
+ if (!visible) {
501
+ continue;
502
+ }
503
+
504
+ oldToNew[outputId] = this.nodeDef.output.length;
505
+ this.newToOldOutputMap[this.nodeDef.output.length] = { node, slot: outputId };
506
+ this.nodeDef.output.push(def.output[outputId]);
507
+ this.nodeDef.output_is_list.push(def.output_is_list[outputId]);
508
+
509
+ let label = customConfig?.name;
510
+ if (!label) {
511
+ label = def.output_name?.[outputId] ?? def.output[outputId];
512
+ const output = node.outputs.find((o) => o.name === label);
513
+ if (output?.label) {
514
+ label = output.label;
515
+ }
516
+ }
517
+
518
+ let name = label;
519
+ if (name in seenOutputs) {
520
+ const prefix = `${node.title ?? node.type} `;
521
+ name = `${prefix}${label}`;
522
+ if (name in seenOutputs) {
523
+ name = `${prefix}${node.index} ${label}`;
524
+ }
525
+ }
526
+ seenOutputs[name] = 1;
527
+
528
+ this.nodeDef.output_name.push(name);
529
+ }
530
+ }
531
+
532
+ static async registerFromWorkflow(groupNodes, missingNodeTypes) {
533
+ const clean = app.clean;
534
+ app.clean = function () {
535
+ for (const g in groupNodes) {
536
+ try {
537
+ LiteGraph.unregisterNodeType("workflow/" + g);
538
+ } catch (error) {}
539
+ }
540
+ app.clean = clean;
541
+ };
542
+
543
+ for (const g in groupNodes) {
544
+ const groupData = groupNodes[g];
545
+
546
+ let hasMissing = false;
547
+ for (const n of groupData.nodes) {
548
+ // Find missing node types
549
+ if (!(n.type in LiteGraph.registered_node_types)) {
550
+ missingNodeTypes.push({
551
+ type: n.type,
552
+ hint: ` (In group node 'workflow/${g}')`,
553
+ });
554
+
555
+ missingNodeTypes.push({
556
+ type: "workflow/" + g,
557
+ action: {
558
+ text: "Remove from workflow",
559
+ callback: (e) => {
560
+ delete groupNodes[g];
561
+ e.target.textContent = "Removed";
562
+ e.target.style.pointerEvents = "none";
563
+ e.target.style.opacity = 0.7;
564
+ },
565
+ },
566
+ });
567
+
568
+ hasMissing = true;
569
+ }
570
+ }
571
+
572
+ if (hasMissing) continue;
573
+
574
+ const config = new GroupNodeConfig(g, groupData);
575
+ await config.registerType();
576
+ }
577
+ }
578
+ }
579
+
580
+ export class GroupNodeHandler {
581
+ node;
582
+ groupData;
583
+
584
+ constructor(node) {
585
+ this.node = node;
586
+ this.groupData = node.constructor?.nodeData?.[GROUP];
587
+
588
+ this.node.setInnerNodes = (innerNodes) => {
589
+ this.innerNodes = innerNodes;
590
+
591
+ for (let innerNodeIndex = 0; innerNodeIndex < this.innerNodes.length; innerNodeIndex++) {
592
+ const innerNode = this.innerNodes[innerNodeIndex];
593
+
594
+ for (const w of innerNode.widgets ?? []) {
595
+ if (w.type === "converted-widget") {
596
+ w.serializeValue = w.origSerializeValue;
597
+ }
598
+ }
599
+
600
+ innerNode.index = innerNodeIndex;
601
+ innerNode.getInputNode = (slot) => {
602
+ // Check if this input is internal or external
603
+ const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
604
+ if (externalSlot != null) {
605
+ return this.node.getInputNode(externalSlot);
606
+ }
607
+
608
+ // Internal link
609
+ const innerLink = this.groupData.linksTo[innerNode.index]?.[slot];
610
+ if (!innerLink) return null;
611
+
612
+ const inputNode = innerNodes[innerLink[0]];
613
+ // Primitives will already apply their values
614
+ if (inputNode.type === "PrimitiveNode") return null;
615
+
616
+ return inputNode;
617
+ };
618
+
619
+ innerNode.getInputLink = (slot) => {
620
+ const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
621
+ if (externalSlot != null) {
622
+ // The inner node is connected via the group node inputs
623
+ const linkId = this.node.inputs[externalSlot].link;
624
+ let link = app.graph.links[linkId];
625
+
626
+ // Use the outer link, but update the target to the inner node
627
+ link = {
628
+ ...link,
629
+ target_id: innerNode.id,
630
+ target_slot: +slot,
631
+ };
632
+ return link;
633
+ }
634
+
635
+ let link = this.groupData.linksTo[innerNode.index]?.[slot];
636
+ if (!link) return null;
637
+ // Use the inner link, but update the origin node to be inner node id
638
+ link = {
639
+ origin_id: innerNodes[link[0]].id,
640
+ origin_slot: link[1],
641
+ target_id: innerNode.id,
642
+ target_slot: +slot,
643
+ };
644
+ return link;
645
+ };
646
+ }
647
+ };
648
+
649
+ this.node.updateLink = (link) => {
650
+ // Replace the group node reference with the internal node
651
+ link = { ...link };
652
+ const output = this.groupData.newToOldOutputMap[link.origin_slot];
653
+ let innerNode = this.innerNodes[output.node.index];
654
+ let l;
655
+ while (innerNode?.type === "Reroute") {
656
+ l = innerNode.getInputLink(0);
657
+ innerNode = innerNode.getInputNode(0);
658
+ }
659
+
660
+ if (!innerNode) {
661
+ return null;
662
+ }
663
+
664
+ if (l && GroupNodeHandler.isGroupNode(innerNode)) {
665
+ return innerNode.updateLink(l);
666
+ }
667
+
668
+ link.origin_id = innerNode.id;
669
+ link.origin_slot = l?.origin_slot ?? output.slot;
670
+ return link;
671
+ };
672
+
673
+ this.node.getInnerNodes = () => {
674
+ if (!this.innerNodes) {
675
+ this.node.setInnerNodes(
676
+ this.groupData.nodeData.nodes.map((n, i) => {
677
+ const innerNode = LiteGraph.createNode(n.type);
678
+ innerNode.configure(n);
679
+ innerNode.id = `${this.node.id}:${i}`;
680
+ return innerNode;
681
+ })
682
+ );
683
+ }
684
+
685
+ this.updateInnerWidgets();
686
+
687
+ return this.innerNodes;
688
+ };
689
+
690
+ this.node.recreate = async () => {
691
+ const id = this.node.id;
692
+ const sz = this.node.size;
693
+ const nodes = this.node.convertToNodes();
694
+
695
+ const groupNode = LiteGraph.createNode(this.node.type);
696
+ groupNode.id = id;
697
+
698
+ // Reuse the existing nodes for this instance
699
+ groupNode.setInnerNodes(nodes);
700
+ groupNode[GROUP].populateWidgets();
701
+ app.graph.add(groupNode);
702
+ groupNode.size = [Math.max(groupNode.size[0], sz[0]), Math.max(groupNode.size[1], sz[1])];
703
+
704
+ // Remove all converted nodes and relink them
705
+ groupNode[GROUP].replaceNodes(nodes);
706
+ return groupNode;
707
+ };
708
+
709
+ this.node.convertToNodes = () => {
710
+ const addInnerNodes = () => {
711
+ const backup = localStorage.getItem("litegrapheditor_clipboard");
712
+ // Clone the node data so we dont mutate it for other nodes
713
+ const c = { ...this.groupData.nodeData };
714
+ c.nodes = [...c.nodes];
715
+ const innerNodes = this.node.getInnerNodes();
716
+ let ids = [];
717
+ for (let i = 0; i < c.nodes.length; i++) {
718
+ let id = innerNodes?.[i]?.id;
719
+ // Use existing IDs if they are set on the inner nodes
720
+ if (id == null || isNaN(id)) {
721
+ id = undefined;
722
+ } else {
723
+ ids.push(id);
724
+ }
725
+ c.nodes[i] = { ...c.nodes[i], id };
726
+ }
727
+ localStorage.setItem("litegrapheditor_clipboard", JSON.stringify(c));
728
+ app.canvas.pasteFromClipboard();
729
+ localStorage.setItem("litegrapheditor_clipboard", backup);
730
+
731
+ const [x, y] = this.node.pos;
732
+ let top;
733
+ let left;
734
+ // Configure nodes with current widget data
735
+ const selectedIds = ids.length ? ids : Object.keys(app.canvas.selected_nodes);
736
+ const newNodes = [];
737
+ for (let i = 0; i < selectedIds.length; i++) {
738
+ const id = selectedIds[i];
739
+ const newNode = app.graph.getNodeById(id);
740
+ const innerNode = innerNodes[i];
741
+ newNodes.push(newNode);
742
+
743
+ if (left == null || newNode.pos[0] < left) {
744
+ left = newNode.pos[0];
745
+ }
746
+ if (top == null || newNode.pos[1] < top) {
747
+ top = newNode.pos[1];
748
+ }
749
+
750
+ if (!newNode.widgets) continue;
751
+
752
+ const map = this.groupData.oldToNewWidgetMap[innerNode.index];
753
+ if (map) {
754
+ const widgets = Object.keys(map);
755
+
756
+ for (const oldName of widgets) {
757
+ const newName = map[oldName];
758
+ if (!newName) continue;
759
+
760
+ const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
761
+ if (widgetIndex === -1) continue;
762
+
763
+ // Populate the main and any linked widgets
764
+ if (innerNode.type === "PrimitiveNode") {
765
+ for (let i = 0; i < newNode.widgets.length; i++) {
766
+ newNode.widgets[i].value = this.node.widgets[widgetIndex + i].value;
767
+ }
768
+ } else {
769
+ const outerWidget = this.node.widgets[widgetIndex];
770
+ const newWidget = newNode.widgets.find((w) => w.name === oldName);
771
+ if (!newWidget) continue;
772
+
773
+ newWidget.value = outerWidget.value;
774
+ for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) {
775
+ newWidget.linkedWidgets[w].value = outerWidget.linkedWidgets[w].value;
776
+ }
777
+ }
778
+ }
779
+ }
780
+ }
781
+
782
+ // Shift each node
783
+ for (const newNode of newNodes) {
784
+ newNode.pos = [newNode.pos[0] - (left - x), newNode.pos[1] - (top - y)];
785
+ }
786
+
787
+ return { newNodes, selectedIds };
788
+ };
789
+
790
+ const reconnectInputs = (selectedIds) => {
791
+ for (const innerNodeIndex in this.groupData.oldToNewInputMap) {
792
+ const id = selectedIds[innerNodeIndex];
793
+ const newNode = app.graph.getNodeById(id);
794
+ const map = this.groupData.oldToNewInputMap[innerNodeIndex];
795
+ for (const innerInputId in map) {
796
+ const groupSlotId = map[innerInputId];
797
+ if (groupSlotId == null) continue;
798
+ const slot = node.inputs[groupSlotId];
799
+ if (slot.link == null) continue;
800
+ const link = app.graph.links[slot.link];
801
+ if (!link) continue;
802
+ // connect this node output to the input of another node
803
+ const originNode = app.graph.getNodeById(link.origin_id);
804
+ originNode.connect(link.origin_slot, newNode, +innerInputId);
805
+ }
806
+ }
807
+ };
808
+
809
+ const reconnectOutputs = (selectedIds) => {
810
+ for (let groupOutputId = 0; groupOutputId < node.outputs?.length; groupOutputId++) {
811
+ const output = node.outputs[groupOutputId];
812
+ if (!output.links) continue;
813
+ const links = [...output.links];
814
+ for (const l of links) {
815
+ const slot = this.groupData.newToOldOutputMap[groupOutputId];
816
+ const link = app.graph.links[l];
817
+ const targetNode = app.graph.getNodeById(link.target_id);
818
+ const newNode = app.graph.getNodeById(selectedIds[slot.node.index]);
819
+ newNode.connect(slot.slot, targetNode, link.target_slot);
820
+ }
821
+ }
822
+ };
823
+
824
+ const { newNodes, selectedIds } = addInnerNodes();
825
+ reconnectInputs(selectedIds);
826
+ reconnectOutputs(selectedIds);
827
+ app.graph.remove(this.node);
828
+
829
+ return newNodes;
830
+ };
831
+
832
+ const getExtraMenuOptions = this.node.getExtraMenuOptions;
833
+ this.node.getExtraMenuOptions = function (_, options) {
834
+ getExtraMenuOptions?.apply(this, arguments);
835
+
836
+ let optionIndex = options.findIndex((o) => o.content === "Outputs");
837
+ if (optionIndex === -1) optionIndex = options.length;
838
+ else optionIndex++;
839
+ options.splice(
840
+ optionIndex,
841
+ 0,
842
+ null,
843
+ {
844
+ content: "Convert to nodes",
845
+ callback: () => {
846
+ return this.convertToNodes();
847
+ },
848
+ },
849
+ {
850
+ content: "Manage Group Node",
851
+ callback: () => {
852
+ new ManageGroupDialog(app).show(this.type);
853
+ },
854
+ }
855
+ );
856
+ };
857
+
858
+ // Draw custom collapse icon to identity this as a group
859
+ const onDrawTitleBox = this.node.onDrawTitleBox;
860
+ this.node.onDrawTitleBox = function (ctx, height, size, scale) {
861
+ onDrawTitleBox?.apply(this, arguments);
862
+
863
+ const fill = ctx.fillStyle;
864
+ ctx.beginPath();
865
+ ctx.rect(11, -height + 11, 2, 2);
866
+ ctx.rect(14, -height + 11, 2, 2);
867
+ ctx.rect(17, -height + 11, 2, 2);
868
+ ctx.rect(11, -height + 14, 2, 2);
869
+ ctx.rect(14, -height + 14, 2, 2);
870
+ ctx.rect(17, -height + 14, 2, 2);
871
+ ctx.rect(11, -height + 17, 2, 2);
872
+ ctx.rect(14, -height + 17, 2, 2);
873
+ ctx.rect(17, -height + 17, 2, 2);
874
+
875
+ ctx.fillStyle = this.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
876
+ ctx.fill();
877
+ ctx.fillStyle = fill;
878
+ };
879
+
880
+ // Draw progress label
881
+ const onDrawForeground = node.onDrawForeground;
882
+ const groupData = this.groupData.nodeData;
883
+ node.onDrawForeground = function (ctx) {
884
+ const r = onDrawForeground?.apply?.(this, arguments);
885
+ if (+app.runningNodeId === this.id && this.runningInternalNodeId !== null) {
886
+ const n = groupData.nodes[this.runningInternalNodeId];
887
+ if(!n) return;
888
+ const message = `Running ${n.title || n.type} (${this.runningInternalNodeId}/${groupData.nodes.length})`;
889
+ ctx.save();
890
+ ctx.font = "12px sans-serif";
891
+ const sz = ctx.measureText(message);
892
+ ctx.fillStyle = node.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
893
+ ctx.beginPath();
894
+ ctx.roundRect(0, -LiteGraph.NODE_TITLE_HEIGHT - 20, sz.width + 12, 20, 5);
895
+ ctx.fill();
896
+
897
+ ctx.fillStyle = "#fff";
898
+ ctx.fillText(message, 6, -LiteGraph.NODE_TITLE_HEIGHT - 6);
899
+ ctx.restore();
900
+ }
901
+ };
902
+
903
+ // Flag this node as needing to be reset
904
+ const onExecutionStart = this.node.onExecutionStart;
905
+ this.node.onExecutionStart = function () {
906
+ this.resetExecution = true;
907
+ return onExecutionStart?.apply(this, arguments);
908
+ };
909
+
910
+ const self = this;
911
+ const onNodeCreated = this.node.onNodeCreated;
912
+ this.node.onNodeCreated = function () {
913
+ if (!this.widgets) {
914
+ return;
915
+ }
916
+ const config = self.groupData.nodeData.config;
917
+ if (config) {
918
+ for (const n in config) {
919
+ const inputs = config[n]?.input;
920
+ for (const w in inputs) {
921
+ if (inputs[w].visible !== false) continue;
922
+ const widgetName = self.groupData.oldToNewWidgetMap[n][w];
923
+ const widget = this.widgets.find((w) => w.name === widgetName);
924
+ if (widget) {
925
+ widget.type = "hidden";
926
+ widget.computeSize = () => [0, -4];
927
+ }
928
+ }
929
+ }
930
+ }
931
+
932
+ return onNodeCreated?.apply(this, arguments);
933
+ };
934
+
935
+ function handleEvent(type, getId, getEvent) {
936
+ const handler = ({ detail }) => {
937
+ const id = getId(detail);
938
+ if (!id) return;
939
+ const node = app.graph.getNodeById(id);
940
+ if (node) return;
941
+
942
+ const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == id);
943
+ if (innerNodeIndex > -1) {
944
+ this.node.runningInternalNodeId = innerNodeIndex;
945
+ api.dispatchEvent(new CustomEvent(type, { detail: getEvent(detail, this.node.id + "", this.node) }));
946
+ }
947
+ };
948
+ api.addEventListener(type, handler);
949
+ return handler;
950
+ }
951
+
952
+ const executing = handleEvent.call(
953
+ this,
954
+ "executing",
955
+ (d) => d,
956
+ (d, id, node) => id
957
+ );
958
+
959
+ const executed = handleEvent.call(
960
+ this,
961
+ "executed",
962
+ (d) => d?.node,
963
+ (d, id, node) => ({ ...d, node: id, merge: !node.resetExecution })
964
+ );
965
+
966
+ const onRemoved = node.onRemoved;
967
+ this.node.onRemoved = function () {
968
+ onRemoved?.apply(this, arguments);
969
+ api.removeEventListener("executing", executing);
970
+ api.removeEventListener("executed", executed);
971
+ };
972
+
973
+ this.node.refreshComboInNode = (defs) => {
974
+ // Update combo widget options
975
+ for (const widgetName in this.groupData.newToOldWidgetMap) {
976
+ const widget = this.node.widgets.find((w) => w.name === widgetName);
977
+ if (widget?.type === "combo") {
978
+ const old = this.groupData.newToOldWidgetMap[widgetName];
979
+ const def = defs[old.node.type];
980
+ const input = def?.input?.required?.[old.inputName] ?? def?.input?.optional?.[old.inputName];
981
+ if (!input) continue;
982
+
983
+ widget.options.values = input[0];
984
+
985
+ if (old.inputName !== "image" && !widget.options.values.includes(widget.value)) {
986
+ widget.value = widget.options.values[0];
987
+ widget.callback(widget.value);
988
+ }
989
+ }
990
+ }
991
+ };
992
+ }
993
+
994
+ updateInnerWidgets() {
995
+ for (const newWidgetName in this.groupData.newToOldWidgetMap) {
996
+ const newWidget = this.node.widgets.find((w) => w.name === newWidgetName);
997
+ if (!newWidget) continue;
998
+
999
+ const newValue = newWidget.value;
1000
+ const old = this.groupData.newToOldWidgetMap[newWidgetName];
1001
+ let innerNode = this.innerNodes[old.node.index];
1002
+
1003
+ if (innerNode.type === "PrimitiveNode") {
1004
+ innerNode.primitiveValue = newValue;
1005
+ const primitiveLinked = this.groupData.primitiveToWidget[old.node.index];
1006
+ for (const linked of primitiveLinked ?? []) {
1007
+ const node = this.innerNodes[linked.nodeId];
1008
+ const widget = node.widgets.find((w) => w.name === linked.inputName);
1009
+
1010
+ if (widget) {
1011
+ widget.value = newValue;
1012
+ }
1013
+ }
1014
+ continue;
1015
+ } else if (innerNode.type === "Reroute") {
1016
+ const rerouteLinks = this.groupData.linksFrom[old.node.index];
1017
+ if (rerouteLinks) {
1018
+ for (const [_, , targetNodeId, targetSlot] of rerouteLinks["0"]) {
1019
+ const node = this.innerNodes[targetNodeId];
1020
+ const input = node.inputs[targetSlot];
1021
+ if (input.widget) {
1022
+ const widget = node.widgets?.find((w) => w.name === input.widget.name);
1023
+ if (widget) {
1024
+ widget.value = newValue;
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ const widget = innerNode.widgets?.find((w) => w.name === old.inputName);
1032
+ if (widget) {
1033
+ widget.value = newValue;
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ populatePrimitive(node, nodeId, oldName, i, linkedShift) {
1039
+ // Converted widget, populate primitive if linked
1040
+ const primitiveId = this.groupData.widgetToPrimitive[nodeId]?.[oldName];
1041
+ if (primitiveId == null) return;
1042
+ const targetWidgetName = this.groupData.oldToNewWidgetMap[primitiveId]["value"];
1043
+ const targetWidgetIndex = this.node.widgets.findIndex((w) => w.name === targetWidgetName);
1044
+ if (targetWidgetIndex > -1) {
1045
+ const primitiveNode = this.innerNodes[primitiveId];
1046
+ let len = primitiveNode.widgets.length;
1047
+ if (len - 1 !== this.node.widgets[targetWidgetIndex].linkedWidgets?.length) {
1048
+ // Fallback handling for if some reason the primitive has a different number of widgets
1049
+ // we dont want to overwrite random widgets, better to leave blank
1050
+ len = 1;
1051
+ }
1052
+ for (let i = 0; i < len; i++) {
1053
+ this.node.widgets[targetWidgetIndex + i].value = primitiveNode.widgets[i].value;
1054
+ }
1055
+ }
1056
+ return true;
1057
+ }
1058
+
1059
+ populateReroute(node, nodeId, map) {
1060
+ if (node.type !== "Reroute") return;
1061
+
1062
+ const link = this.groupData.linksFrom[nodeId]?.[0]?.[0];
1063
+ if (!link) return;
1064
+ const [, , targetNodeId, targetNodeSlot] = link;
1065
+ const targetNode = this.groupData.nodeData.nodes[targetNodeId];
1066
+ const inputs = targetNode.inputs;
1067
+ const targetWidget = inputs?.[targetNodeSlot]?.widget;
1068
+ if (!targetWidget) return;
1069
+
1070
+ const offset = inputs.length - (targetNode.widgets_values?.length ?? 0);
1071
+ const v = targetNode.widgets_values?.[targetNodeSlot - offset];
1072
+ if (v == null) return;
1073
+
1074
+ const widgetName = Object.values(map)[0];
1075
+ const widget = this.node.widgets.find((w) => w.name === widgetName);
1076
+ if (widget) {
1077
+ widget.value = v;
1078
+ }
1079
+ }
1080
+
1081
+ populateWidgets() {
1082
+ if (!this.node.widgets) return;
1083
+
1084
+ for (let nodeId = 0; nodeId < this.groupData.nodeData.nodes.length; nodeId++) {
1085
+ const node = this.groupData.nodeData.nodes[nodeId];
1086
+ const map = this.groupData.oldToNewWidgetMap[nodeId] ?? {};
1087
+ const widgets = Object.keys(map);
1088
+
1089
+ if (!node.widgets_values?.length) {
1090
+ // special handling for populating values into reroutes
1091
+ // this allows primitives connect to them to pick up the correct value
1092
+ this.populateReroute(node, nodeId, map);
1093
+ continue;
1094
+ }
1095
+
1096
+ let linkedShift = 0;
1097
+ for (let i = 0; i < widgets.length; i++) {
1098
+ const oldName = widgets[i];
1099
+ const newName = map[oldName];
1100
+ const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
1101
+ const mainWidget = this.node.widgets[widgetIndex];
1102
+ if (this.populatePrimitive(node, nodeId, oldName, i, linkedShift) || widgetIndex === -1) {
1103
+ // Find the inner widget and shift by the number of linked widgets as they will have been removed too
1104
+ const innerWidget = this.innerNodes[nodeId].widgets?.find((w) => w.name === oldName);
1105
+ linkedShift += innerWidget?.linkedWidgets?.length ?? 0;
1106
+ }
1107
+ if (widgetIndex === -1) {
1108
+ continue;
1109
+ }
1110
+
1111
+ // Populate the main and any linked widget
1112
+ mainWidget.value = node.widgets_values[i + linkedShift];
1113
+ for (let w = 0; w < mainWidget.linkedWidgets?.length; w++) {
1114
+ this.node.widgets[widgetIndex + w + 1].value = node.widgets_values[i + ++linkedShift];
1115
+ }
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ replaceNodes(nodes) {
1121
+ let top;
1122
+ let left;
1123
+
1124
+ for (let i = 0; i < nodes.length; i++) {
1125
+ const node = nodes[i];
1126
+ if (left == null || node.pos[0] < left) {
1127
+ left = node.pos[0];
1128
+ }
1129
+ if (top == null || node.pos[1] < top) {
1130
+ top = node.pos[1];
1131
+ }
1132
+
1133
+ this.linkOutputs(node, i);
1134
+ app.graph.remove(node);
1135
+ }
1136
+
1137
+ this.linkInputs();
1138
+ this.node.pos = [left, top];
1139
+ }
1140
+
1141
+ linkOutputs(originalNode, nodeId) {
1142
+ if (!originalNode.outputs) return;
1143
+
1144
+ for (const output of originalNode.outputs) {
1145
+ if (!output.links) continue;
1146
+ // Clone the links as they'll be changed if we reconnect
1147
+ const links = [...output.links];
1148
+ for (const l of links) {
1149
+ const link = app.graph.links[l];
1150
+ if (!link) continue;
1151
+
1152
+ const targetNode = app.graph.getNodeById(link.target_id);
1153
+ const newSlot = this.groupData.oldToNewOutputMap[nodeId]?.[link.origin_slot];
1154
+ if (newSlot != null) {
1155
+ this.node.connect(newSlot, targetNode, link.target_slot);
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+
1161
+ linkInputs() {
1162
+ for (const link of this.groupData.nodeData.links ?? []) {
1163
+ const [, originSlot, targetId, targetSlot, actualOriginId] = link;
1164
+ const originNode = app.graph.getNodeById(actualOriginId);
1165
+ if (!originNode) continue; // this node is in the group
1166
+ originNode.connect(originSlot, this.node.id, this.groupData.oldToNewInputMap[targetId][targetSlot]);
1167
+ }
1168
+ }
1169
+
1170
+ static getGroupData(node) {
1171
+ return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP];
1172
+ }
1173
+
1174
+ static isGroupNode(node) {
1175
+ return !!node.constructor?.nodeData?.[GROUP];
1176
+ }
1177
+
1178
+ static async fromNodes(nodes) {
1179
+ // Process the nodes into the stored workflow group node data
1180
+ const builder = new GroupNodeBuilder(nodes);
1181
+ const res = builder.build();
1182
+ if (!res) return;
1183
+
1184
+ const { name, nodeData } = res;
1185
+
1186
+ // Convert this data into a LG node definition and register it
1187
+ const config = new GroupNodeConfig(name, nodeData);
1188
+ await config.registerType();
1189
+
1190
+ const groupNode = LiteGraph.createNode(`workflow/${name}`);
1191
+ // Reuse the existing nodes for this instance
1192
+ groupNode.setInnerNodes(builder.nodes);
1193
+ groupNode[GROUP].populateWidgets();
1194
+ app.graph.add(groupNode);
1195
+
1196
+ // Remove all converted nodes and relink them
1197
+ groupNode[GROUP].replaceNodes(builder.nodes);
1198
+ return groupNode;
1199
+ }
1200
+ }
1201
+
1202
+ function addConvertToGroupOptions() {
1203
+ function addConvertOption(options, index) {
1204
+ const selected = Object.values(app.canvas.selected_nodes ?? {});
1205
+ const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n));
1206
+ options.splice(index + 1, null, {
1207
+ content: `Convert to Group Node`,
1208
+ disabled,
1209
+ callback: async () => {
1210
+ return await GroupNodeHandler.fromNodes(selected);
1211
+ },
1212
+ });
1213
+ }
1214
+
1215
+ function addManageOption(options, index) {
1216
+ const groups = app.graph.extra?.groupNodes;
1217
+ const disabled = !groups || !Object.keys(groups).length;
1218
+ options.splice(index + 1, null, {
1219
+ content: `Manage Group Nodes`,
1220
+ disabled,
1221
+ callback: () => {
1222
+ new ManageGroupDialog(app).show();
1223
+ },
1224
+ });
1225
+ }
1226
+
1227
+ // Add to canvas
1228
+ const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
1229
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
1230
+ const options = getCanvasMenuOptions.apply(this, arguments);
1231
+ const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length;
1232
+ addConvertOption(options, index);
1233
+ addManageOption(options, index + 1);
1234
+ return options;
1235
+ };
1236
+
1237
+ // Add to nodes
1238
+ const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
1239
+ LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
1240
+ const options = getNodeMenuOptions.apply(this, arguments);
1241
+ if (!GroupNodeHandler.isGroupNode(node)) {
1242
+ const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1;
1243
+ addConvertOption(options, index);
1244
+ }
1245
+ return options;
1246
+ };
1247
+ }
1248
+
1249
+ const id = "Comfy.GroupNode";
1250
+ let globalDefs;
1251
+ const ext = {
1252
+ name: id,
1253
+ setup() {
1254
+ addConvertToGroupOptions();
1255
+ },
1256
+ async beforeConfigureGraph(graphData, missingNodeTypes) {
1257
+ const nodes = graphData?.extra?.groupNodes;
1258
+ if (nodes) {
1259
+ await GroupNodeConfig.registerFromWorkflow(nodes, missingNodeTypes);
1260
+ }
1261
+ },
1262
+ addCustomNodeDefs(defs) {
1263
+ // Store this so we can mutate it later with group nodes
1264
+ globalDefs = defs;
1265
+ },
1266
+ nodeCreated(node) {
1267
+ if (GroupNodeHandler.isGroupNode(node)) {
1268
+ node[GROUP] = new GroupNodeHandler(node);
1269
+ }
1270
+ },
1271
+ async refreshComboInNodes(defs) {
1272
+ // Re-register group nodes so new ones are created with the correct options
1273
+ Object.assign(globalDefs, defs);
1274
+ const nodes = app.graph.extra?.groupNodes;
1275
+ if (nodes) {
1276
+ await GroupNodeConfig.registerFromWorkflow(nodes, {});
1277
+ }
1278
+ }
1279
+ };
1280
+
1281
+ app.registerExtension(ext);
ComfyUI/web/extensions/core/groupNodeManage.css ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .comfy-group-manage {
2
+ background: var(--bg-color);
3
+ color: var(--fg-color);
4
+ padding: 0;
5
+ font-family: Arial, Helvetica, sans-serif;
6
+ border-color: black;
7
+ margin: 20vh auto;
8
+ max-height: 60vh;
9
+ }
10
+ .comfy-group-manage-outer {
11
+ max-height: 60vh;
12
+ min-width: 500px;
13
+ display: flex;
14
+ flex-direction: column;
15
+ }
16
+ .comfy-group-manage-outer > header {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 10px;
20
+ justify-content: space-between;
21
+ background: var(--comfy-menu-bg);
22
+ padding: 15px 20px;
23
+ }
24
+ .comfy-group-manage-outer > header select {
25
+ background: var(--comfy-input-bg);
26
+ border: 1px solid var(--border-color);
27
+ color: var(--input-text);
28
+ padding: 5px 10px;
29
+ border-radius: 5px;
30
+ }
31
+ .comfy-group-manage h2 {
32
+ margin: 0;
33
+ font-weight: normal;
34
+ }
35
+ .comfy-group-manage main {
36
+ display: flex;
37
+ overflow: hidden;
38
+ }
39
+ .comfy-group-manage .drag-handle {
40
+ font-weight: bold;
41
+ }
42
+ .comfy-group-manage-list {
43
+ border-right: 1px solid var(--comfy-menu-bg);
44
+ }
45
+ .comfy-group-manage-list ul {
46
+ margin: 40px 0 0;
47
+ padding: 0;
48
+ list-style: none;
49
+ }
50
+ .comfy-group-manage-list-items {
51
+ max-height: calc(100% - 40px);
52
+ overflow-y: scroll;
53
+ overflow-x: hidden;
54
+ }
55
+ .comfy-group-manage-list li {
56
+ display: flex;
57
+ padding: 10px 20px 10px 10px;
58
+ cursor: pointer;
59
+ align-items: center;
60
+ gap: 5px;
61
+ }
62
+ .comfy-group-manage-list div {
63
+ display: flex;
64
+ flex-direction: column;
65
+ }
66
+ .comfy-group-manage-list li:not(.selected):hover div {
67
+ text-decoration: underline;
68
+ }
69
+ .comfy-group-manage-list li.selected {
70
+ background: var(--border-color);
71
+ }
72
+ .comfy-group-manage-list li span {
73
+ opacity: 0.7;
74
+ font-size: smaller;
75
+ }
76
+ .comfy-group-manage-node {
77
+ flex: auto;
78
+ background: var(--border-color);
79
+ display: flex;
80
+ flex-direction: column;
81
+ }
82
+ .comfy-group-manage-node > div {
83
+ overflow: auto;
84
+ }
85
+ .comfy-group-manage-node header {
86
+ display: flex;
87
+ background: var(--bg-color);
88
+ height: 40px;
89
+ }
90
+ .comfy-group-manage-node header a {
91
+ text-align: center;
92
+ flex: auto;
93
+ border-right: 1px solid var(--comfy-menu-bg);
94
+ border-bottom: 1px solid var(--comfy-menu-bg);
95
+ padding: 10px;
96
+ cursor: pointer;
97
+ font-size: 15px;
98
+ }
99
+ .comfy-group-manage-node header a:last-child {
100
+ border-right: none;
101
+ }
102
+ .comfy-group-manage-node header a:not(.active):hover {
103
+ text-decoration: underline;
104
+ }
105
+ .comfy-group-manage-node header a.active {
106
+ background: var(--border-color);
107
+ border-bottom: none;
108
+ }
109
+ .comfy-group-manage-node-page {
110
+ display: none;
111
+ overflow: auto;
112
+ }
113
+ .comfy-group-manage-node-page.active {
114
+ display: block;
115
+ }
116
+ .comfy-group-manage-node-page div {
117
+ padding: 10px;
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 10px;
121
+ }
122
+ .comfy-group-manage-node-page input {
123
+ border: none;
124
+ color: var(--input-text);
125
+ background: var(--comfy-input-bg);
126
+ padding: 5px 10px;
127
+ }
128
+ .comfy-group-manage-node-page input[type="text"] {
129
+ flex: auto;
130
+ }
131
+ .comfy-group-manage-node-page label {
132
+ display: flex;
133
+ gap: 5px;
134
+ align-items: center;
135
+ }
136
+ .comfy-group-manage footer {
137
+ border-top: 1px solid var(--comfy-menu-bg);
138
+ padding: 10px;
139
+ display: flex;
140
+ gap: 10px;
141
+ }
142
+ .comfy-group-manage footer button {
143
+ font-size: 14px;
144
+ padding: 5px 10px;
145
+ border-radius: 0;
146
+ }
147
+ .comfy-group-manage footer button:first-child {
148
+ margin-right: auto;
149
+ }
ComfyUI/web/extensions/core/groupNodeManage.js ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el, ComfyDialog } from "../../scripts/ui.js";
2
+ import { DraggableList } from "../../scripts/ui/draggableList.js";
3
+ import { addStylesheet } from "../../scripts/utils.js";
4
+ import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
5
+
6
+ addStylesheet(import.meta.url);
7
+
8
+ const ORDER = Symbol();
9
+
10
+ function merge(target, source) {
11
+ if (typeof target === "object" && typeof source === "object") {
12
+ for (const key in source) {
13
+ const sv = source[key];
14
+ if (typeof sv === "object") {
15
+ let tv = target[key];
16
+ if (!tv) tv = target[key] = {};
17
+ merge(tv, source[key]);
18
+ } else {
19
+ target[key] = sv;
20
+ }
21
+ }
22
+ }
23
+
24
+ return target;
25
+ }
26
+
27
+ export class ManageGroupDialog extends ComfyDialog {
28
+ /** @type { Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}> } */
29
+ tabs = {};
30
+ /** @type { number | null | undefined } */
31
+ selectedNodeIndex;
32
+ /** @type { keyof ManageGroupDialog["tabs"] } */
33
+ selectedTab = "Inputs";
34
+ /** @type { string | undefined } */
35
+ selectedGroup;
36
+
37
+ /** @type { Record<string, Record<string, Record<string, { name?: string | undefined, visible?: boolean | undefined }>>> } */
38
+ modifications = {};
39
+
40
+ get selectedNodeInnerIndex() {
41
+ return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex;
42
+ }
43
+
44
+ constructor(app) {
45
+ super();
46
+ this.app = app;
47
+ this.element = $el("dialog.comfy-group-manage", {
48
+ parent: document.body,
49
+ });
50
+ }
51
+
52
+ changeTab(tab) {
53
+ this.tabs[this.selectedTab].tab.classList.remove("active");
54
+ this.tabs[this.selectedTab].page.classList.remove("active");
55
+ this.tabs[tab].tab.classList.add("active");
56
+ this.tabs[tab].page.classList.add("active");
57
+ this.selectedTab = tab;
58
+ }
59
+
60
+ changeNode(index, force) {
61
+ if (!force && this.selectedNodeIndex === index) return;
62
+
63
+ if (this.selectedNodeIndex != null) {
64
+ this.nodeItems[this.selectedNodeIndex].classList.remove("selected");
65
+ }
66
+ this.nodeItems[index].classList.add("selected");
67
+ this.selectedNodeIndex = index;
68
+
69
+ if (!this.buildInputsPage() && this.selectedTab === "Inputs") {
70
+ this.changeTab("Widgets");
71
+ }
72
+ if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") {
73
+ this.changeTab("Outputs");
74
+ }
75
+ if (!this.buildOutputsPage() && this.selectedTab === "Outputs") {
76
+ this.changeTab("Inputs");
77
+ }
78
+
79
+ this.changeTab(this.selectedTab);
80
+ }
81
+
82
+ getGroupData() {
83
+ this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup];
84
+ this.groupNodeDef = this.groupNodeType.nodeData;
85
+ this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType);
86
+ }
87
+
88
+ changeGroup(group, reset = true) {
89
+ this.selectedGroup = group;
90
+ this.getGroupData();
91
+
92
+ const nodes = this.groupData.nodeData.nodes;
93
+ this.nodeItems = nodes.map((n, i) =>
94
+ $el(
95
+ "li.draggable-item",
96
+ {
97
+ dataset: {
98
+ nodeindex: n.index + "",
99
+ },
100
+ onclick: () => {
101
+ this.changeNode(i);
102
+ },
103
+ },
104
+ [
105
+ $el("span.drag-handle"),
106
+ $el(
107
+ "div",
108
+ {
109
+ textContent: n.title ?? n.type,
110
+ },
111
+ n.title
112
+ ? $el("span", {
113
+ textContent: n.type,
114
+ })
115
+ : []
116
+ ),
117
+ ]
118
+ )
119
+ );
120
+
121
+ this.innerNodesList.replaceChildren(...this.nodeItems);
122
+
123
+ if (reset) {
124
+ this.selectedNodeIndex = null;
125
+ this.changeNode(0);
126
+ } else {
127
+ const items = this.draggable.getAllItems();
128
+ let index = items.findIndex(item => item.classList.contains("selected"));
129
+ if(index === -1) index = this.selectedNodeIndex;
130
+ this.changeNode(index, true);
131
+ }
132
+
133
+ const ordered = [...nodes];
134
+ this.draggable?.dispose();
135
+ this.draggable = new DraggableList(this.innerNodesList, "li");
136
+ this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => {
137
+ if (oldPosition === newPosition) return;
138
+ ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]);
139
+ for (let i = 0; i < ordered.length; i++) {
140
+ this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i });
141
+ }
142
+ });
143
+ }
144
+
145
+ storeModification({ nodeIndex, section, prop, value }) {
146
+ const groupMod = (this.modifications[this.selectedGroup] ??= {});
147
+ const nodesMod = (groupMod.nodes ??= {});
148
+ const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {});
149
+ const typeMod = (nodeMod[section] ??= {});
150
+ if (typeof value === "object") {
151
+ const objMod = (typeMod[prop] ??= {});
152
+ Object.assign(objMod, value);
153
+ } else {
154
+ typeMod[prop] = value;
155
+ }
156
+ }
157
+
158
+ getEditElement(section, prop, value, placeholder, checked, checkable = true) {
159
+ if (value === placeholder) value = "";
160
+
161
+ const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop];
162
+ if (mods) {
163
+ if (mods.name != null) {
164
+ value = mods.name;
165
+ }
166
+ if (mods.visible != null) {
167
+ checked = mods.visible;
168
+ }
169
+ }
170
+
171
+ return $el("div", [
172
+ $el("input", {
173
+ value,
174
+ placeholder,
175
+ type: "text",
176
+ onchange: (e) => {
177
+ this.storeModification({ section, prop, value: { name: e.target.value } });
178
+ },
179
+ }),
180
+ $el("label", { textContent: "Visible" }, [
181
+ $el("input", {
182
+ type: "checkbox",
183
+ checked,
184
+ disabled: !checkable,
185
+ onchange: (e) => {
186
+ this.storeModification({ section, prop, value: { visible: !!e.target.checked } });
187
+ },
188
+ }),
189
+ ]),
190
+ ]);
191
+ }
192
+
193
+ buildWidgetsPage() {
194
+ const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex];
195
+ const items = Object.keys(widgets ?? {});
196
+ const type = app.graph.extra.groupNodes[this.selectedGroup];
197
+ const config = type.config?.[this.selectedNodeInnerIndex]?.input;
198
+ this.widgetsPage.replaceChildren(
199
+ ...items.map((oldName) => {
200
+ return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false);
201
+ })
202
+ );
203
+ return !!items.length;
204
+ }
205
+
206
+ buildInputsPage() {
207
+ const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex];
208
+ const items = Object.keys(inputs ?? {});
209
+ const type = app.graph.extra.groupNodes[this.selectedGroup];
210
+ const config = type.config?.[this.selectedNodeInnerIndex]?.input;
211
+ this.inputsPage.replaceChildren(
212
+ ...items
213
+ .map((oldName) => {
214
+ let value = inputs[oldName];
215
+ if (!value) {
216
+ return;
217
+ }
218
+
219
+ return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false);
220
+ })
221
+ .filter(Boolean)
222
+ );
223
+ return !!items.length;
224
+ }
225
+
226
+ buildOutputsPage() {
227
+ const nodes = this.groupData.nodeData.nodes;
228
+ const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]);
229
+ const outputs = innerNodeDef?.output ?? [];
230
+ const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex];
231
+
232
+ const type = app.graph.extra.groupNodes[this.selectedGroup];
233
+ const config = type.config?.[this.selectedNodeInnerIndex]?.output;
234
+ const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex];
235
+ const checkable = node.type !== "PrimitiveNode";
236
+ this.outputsPage.replaceChildren(
237
+ ...outputs
238
+ .map((type, slot) => {
239
+ const groupOutputIndex = groupOutputs?.[slot];
240
+ const oldName = innerNodeDef.output_name?.[slot] ?? type;
241
+ let value = config?.[slot]?.name;
242
+ const visible = config?.[slot]?.visible || groupOutputIndex != null;
243
+ if (!value || value === oldName) {
244
+ value = "";
245
+ }
246
+ return this.getEditElement("output", slot, value, oldName, visible, checkable);
247
+ })
248
+ .filter(Boolean)
249
+ );
250
+ return !!outputs.length;
251
+ }
252
+
253
+ show(type) {
254
+ const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort((a, b) => a.localeCompare(b));
255
+
256
+ this.innerNodesList = $el("ul.comfy-group-manage-list-items");
257
+ this.widgetsPage = $el("section.comfy-group-manage-node-page");
258
+ this.inputsPage = $el("section.comfy-group-manage-node-page");
259
+ this.outputsPage = $el("section.comfy-group-manage-node-page");
260
+ const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]);
261
+
262
+ this.tabs = [
263
+ ["Inputs", this.inputsPage],
264
+ ["Widgets", this.widgetsPage],
265
+ ["Outputs", this.outputsPage],
266
+ ].reduce((p, [name, page]) => {
267
+ p[name] = {
268
+ tab: $el("a", {
269
+ onclick: () => {
270
+ this.changeTab(name);
271
+ },
272
+ textContent: name,
273
+ }),
274
+ page,
275
+ };
276
+ return p;
277
+ }, {});
278
+
279
+ const outer = $el("div.comfy-group-manage-outer", [
280
+ $el("header", [
281
+ $el("h2", "Group Nodes"),
282
+ $el(
283
+ "select",
284
+ {
285
+ onchange: (e) => {
286
+ this.changeGroup(e.target.value);
287
+ },
288
+ },
289
+ groupNodes.map((g) =>
290
+ $el("option", {
291
+ textContent: g,
292
+ selected: "workflow/" + g === type,
293
+ value: g,
294
+ })
295
+ )
296
+ ),
297
+ ]),
298
+ $el("main", [
299
+ $el("section.comfy-group-manage-list", this.innerNodesList),
300
+ $el("section.comfy-group-manage-node", [
301
+ $el(
302
+ "header",
303
+ Object.values(this.tabs).map((t) => t.tab)
304
+ ),
305
+ pages,
306
+ ]),
307
+ ]),
308
+ $el("footer", [
309
+ $el(
310
+ "button.comfy-btn",
311
+ {
312
+ onclick: (e) => {
313
+ const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup);
314
+ if (node) {
315
+ alert("This group node is in use in the current workflow, please first remove these.");
316
+ return;
317
+ }
318
+ if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) {
319
+ delete app.graph.extra.groupNodes[this.selectedGroup];
320
+ LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup);
321
+ }
322
+ this.show();
323
+ },
324
+ },
325
+ "Delete Group Node"
326
+ ),
327
+ $el(
328
+ "button.comfy-btn",
329
+ {
330
+ onclick: async () => {
331
+ let nodesByType;
332
+ let recreateNodes = [];
333
+ const types = {};
334
+ for (const g in this.modifications) {
335
+ const type = app.graph.extra.groupNodes[g];
336
+ let config = (type.config ??= {});
337
+
338
+ let nodeMods = this.modifications[g]?.nodes;
339
+ if (nodeMods) {
340
+ const keys = Object.keys(nodeMods);
341
+ if (nodeMods[keys[0]][ORDER]) {
342
+ // If any node is reordered, they will all need sequencing
343
+ const orderedNodes = [];
344
+ const orderedMods = {};
345
+ const orderedConfig = {};
346
+
347
+ for (const n of keys) {
348
+ const order = nodeMods[n][ORDER].order;
349
+ orderedNodes[order] = type.nodes[+n];
350
+ orderedMods[order] = nodeMods[n];
351
+ orderedNodes[order].index = order;
352
+ }
353
+
354
+ // Rewrite links
355
+ for (const l of type.links) {
356
+ if (l[0] != null) l[0] = type.nodes[l[0]].index;
357
+ if (l[2] != null) l[2] = type.nodes[l[2]].index;
358
+ }
359
+
360
+ // Rewrite externals
361
+ if (type.external) {
362
+ for (const ext of type.external) {
363
+ ext[0] = type.nodes[ext[0]];
364
+ }
365
+ }
366
+
367
+ // Rewrite modifications
368
+ for (const id of keys) {
369
+ if (config[id]) {
370
+ orderedConfig[type.nodes[id].index] = config[id];
371
+ }
372
+ delete config[id];
373
+ }
374
+
375
+ type.nodes = orderedNodes;
376
+ nodeMods = orderedMods;
377
+ type.config = config = orderedConfig;
378
+ }
379
+
380
+ merge(config, nodeMods);
381
+ }
382
+
383
+ types[g] = type;
384
+
385
+ if (!nodesByType) {
386
+ nodesByType = app.graph._nodes.reduce((p, n) => {
387
+ p[n.type] ??= [];
388
+ p[n.type].push(n);
389
+ return p;
390
+ }, {});
391
+ }
392
+
393
+ const nodes = nodesByType["workflow/" + g];
394
+ if (nodes) recreateNodes.push(...nodes);
395
+ }
396
+
397
+ await GroupNodeConfig.registerFromWorkflow(types, {});
398
+
399
+ for (const node of recreateNodes) {
400
+ node.recreate();
401
+ }
402
+
403
+ this.modifications = {};
404
+ this.app.graph.setDirtyCanvas(true, true);
405
+ this.changeGroup(this.selectedGroup, false);
406
+ },
407
+ },
408
+ "Save"
409
+ ),
410
+ $el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"),
411
+ ]),
412
+ ]);
413
+
414
+ this.element.replaceChildren(outer);
415
+ this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]);
416
+ this.element.showModal();
417
+
418
+ this.element.addEventListener("close", () => {
419
+ this.draggable?.dispose();
420
+ });
421
+ }
422
+ }
ComfyUI/web/extensions/core/groupOptions.js ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {app} from "../../scripts/app.js";
2
+
3
+ function setNodeMode(node, mode) {
4
+ node.mode = mode;
5
+ node.graph.change();
6
+ }
7
+
8
+ function addNodesToGroup(group, nodes=[]) {
9
+ var x1, y1, x2, y2;
10
+ var nx1, ny1, nx2, ny2;
11
+ var node;
12
+
13
+ x1 = y1 = x2 = y2 = -1;
14
+ nx1 = ny1 = nx2 = ny2 = -1;
15
+
16
+ for (var n of [group._nodes, nodes]) {
17
+ for (var i in n) {
18
+ node = n[i]
19
+
20
+ nx1 = node.pos[0]
21
+ ny1 = node.pos[1]
22
+ nx2 = node.pos[0] + node.size[0]
23
+ ny2 = node.pos[1] + node.size[1]
24
+
25
+ if (node.type != "Reroute") {
26
+ ny1 -= LiteGraph.NODE_TITLE_HEIGHT;
27
+ }
28
+
29
+ if (node.flags?.collapsed) {
30
+ ny2 = ny1 + LiteGraph.NODE_TITLE_HEIGHT;
31
+
32
+ if (node?._collapsed_width) {
33
+ nx2 = nx1 + Math.round(node._collapsed_width);
34
+ }
35
+ }
36
+
37
+ if (x1 == -1 || nx1 < x1) {
38
+ x1 = nx1;
39
+ }
40
+
41
+ if (y1 == -1 || ny1 < y1) {
42
+ y1 = ny1;
43
+ }
44
+
45
+ if (x2 == -1 || nx2 > x2) {
46
+ x2 = nx2;
47
+ }
48
+
49
+ if (y2 == -1 || ny2 > y2) {
50
+ y2 = ny2;
51
+ }
52
+ }
53
+ }
54
+
55
+ var padding = 10;
56
+
57
+ y1 = y1 - Math.round(group.font_size * 1.4);
58
+
59
+ group.pos = [x1 - padding, y1 - padding];
60
+ group.size = [x2 - x1 + padding * 2, y2 - y1 + padding * 2];
61
+ }
62
+
63
+ app.registerExtension({
64
+ name: "Comfy.GroupOptions",
65
+ setup() {
66
+ const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
67
+ // graph_mouse
68
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
69
+ const options = orig.apply(this, arguments);
70
+ const group = this.graph.getGroupOnPos(this.graph_mouse[0], this.graph_mouse[1]);
71
+ if (!group) {
72
+ options.push({
73
+ content: "Add Group For Selected Nodes",
74
+ disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
75
+ callback: () => {
76
+ var group = new LiteGraph.LGraphGroup();
77
+ addNodesToGroup(group, this.selected_nodes)
78
+ app.canvas.graph.add(group);
79
+ this.graph.change();
80
+ }
81
+ });
82
+
83
+ return options;
84
+ }
85
+
86
+ // Group nodes aren't recomputed until the group is moved, this ensures the nodes are up-to-date
87
+ group.recomputeInsideNodes();
88
+ const nodesInGroup = group._nodes;
89
+
90
+ options.push({
91
+ content: "Add Selected Nodes To Group",
92
+ disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
93
+ callback: () => {
94
+ addNodesToGroup(group, this.selected_nodes)
95
+ this.graph.change();
96
+ }
97
+ });
98
+
99
+ // No nodes in group, return default options
100
+ if (nodesInGroup.length === 0) {
101
+ return options;
102
+ } else {
103
+ // Add a separator between the default options and the group options
104
+ options.push(null);
105
+ }
106
+
107
+ // Check if all nodes are the same mode
108
+ let allNodesAreSameMode = true;
109
+ for (let i = 1; i < nodesInGroup.length; i++) {
110
+ if (nodesInGroup[i].mode !== nodesInGroup[0].mode) {
111
+ allNodesAreSameMode = false;
112
+ break;
113
+ }
114
+ }
115
+
116
+ options.push({
117
+ content: "Fit Group To Nodes",
118
+ callback: () => {
119
+ addNodesToGroup(group)
120
+ this.graph.change();
121
+ }
122
+ });
123
+
124
+ options.push({
125
+ content: "Select Nodes",
126
+ callback: () => {
127
+ this.selectNodes(nodesInGroup);
128
+ this.graph.change();
129
+ this.canvas.focus();
130
+ }
131
+ });
132
+
133
+ // Modes
134
+ // 0: Always
135
+ // 1: On Event
136
+ // 2: Never
137
+ // 3: On Trigger
138
+ // 4: Bypass
139
+ // If all nodes are the same mode, add a menu option to change the mode
140
+ if (allNodesAreSameMode) {
141
+ const mode = nodesInGroup[0].mode;
142
+ switch (mode) {
143
+ case 0:
144
+ // All nodes are always, option to disable, and bypass
145
+ options.push({
146
+ content: "Set Group Nodes to Never",
147
+ callback: () => {
148
+ for (const node of nodesInGroup) {
149
+ setNodeMode(node, 2);
150
+ }
151
+ }
152
+ });
153
+ options.push({
154
+ content: "Bypass Group Nodes",
155
+ callback: () => {
156
+ for (const node of nodesInGroup) {
157
+ setNodeMode(node, 4);
158
+ }
159
+ }
160
+ });
161
+ break;
162
+ case 2:
163
+ // All nodes are never, option to enable, and bypass
164
+ options.push({
165
+ content: "Set Group Nodes to Always",
166
+ callback: () => {
167
+ for (const node of nodesInGroup) {
168
+ setNodeMode(node, 0);
169
+ }
170
+ }
171
+ });
172
+ options.push({
173
+ content: "Bypass Group Nodes",
174
+ callback: () => {
175
+ for (const node of nodesInGroup) {
176
+ setNodeMode(node, 4);
177
+ }
178
+ }
179
+ });
180
+ break;
181
+ case 4:
182
+ // All nodes are bypass, option to enable, and disable
183
+ options.push({
184
+ content: "Set Group Nodes to Always",
185
+ callback: () => {
186
+ for (const node of nodesInGroup) {
187
+ setNodeMode(node, 0);
188
+ }
189
+ }
190
+ });
191
+ options.push({
192
+ content: "Set Group Nodes to Never",
193
+ callback: () => {
194
+ for (const node of nodesInGroup) {
195
+ setNodeMode(node, 2);
196
+ }
197
+ }
198
+ });
199
+ break;
200
+ default:
201
+ // All nodes are On Trigger or On Event(Or other?), option to disable, set to always, or bypass
202
+ options.push({
203
+ content: "Set Group Nodes to Always",
204
+ callback: () => {
205
+ for (const node of nodesInGroup) {
206
+ setNodeMode(node, 0);
207
+ }
208
+ }
209
+ });
210
+ options.push({
211
+ content: "Set Group Nodes to Never",
212
+ callback: () => {
213
+ for (const node of nodesInGroup) {
214
+ setNodeMode(node, 2);
215
+ }
216
+ }
217
+ });
218
+ options.push({
219
+ content: "Bypass Group Nodes",
220
+ callback: () => {
221
+ for (const node of nodesInGroup) {
222
+ setNodeMode(node, 4);
223
+ }
224
+ }
225
+ });
226
+ break;
227
+ }
228
+ } else {
229
+ // Nodes are not all the same mode, add a menu option to change the mode to always, never, or bypass
230
+ options.push({
231
+ content: "Set Group Nodes to Always",
232
+ callback: () => {
233
+ for (const node of nodesInGroup) {
234
+ setNodeMode(node, 0);
235
+ }
236
+ }
237
+ });
238
+ options.push({
239
+ content: "Set Group Nodes to Never",
240
+ callback: () => {
241
+ for (const node of nodesInGroup) {
242
+ setNodeMode(node, 2);
243
+ }
244
+ }
245
+ });
246
+ options.push({
247
+ content: "Bypass Group Nodes",
248
+ callback: () => {
249
+ for (const node of nodesInGroup) {
250
+ setNodeMode(node, 4);
251
+ }
252
+ }
253
+ });
254
+ }
255
+
256
+ return options
257
+ }
258
+ }
259
+ });
ComfyUI/web/extensions/core/invertMenuScrolling.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Inverts the scrolling of context menus
4
+
5
+ const id = "Comfy.InvertMenuScrolling";
6
+ app.registerExtension({
7
+ name: id,
8
+ init() {
9
+ const ctxMenu = LiteGraph.ContextMenu;
10
+ const replace = () => {
11
+ LiteGraph.ContextMenu = function (values, options) {
12
+ options = options || {};
13
+ if (options.scroll_speed) {
14
+ options.scroll_speed *= -1;
15
+ } else {
16
+ options.scroll_speed = -0.1;
17
+ }
18
+ return ctxMenu.call(this, values, options);
19
+ };
20
+ LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
21
+ };
22
+ app.ui.settings.addSetting({
23
+ id,
24
+ name: "Invert Menu Scrolling",
25
+ type: "boolean",
26
+ defaultValue: false,
27
+ onChange(value) {
28
+ if (value) {
29
+ replace();
30
+ } else {
31
+ LiteGraph.ContextMenu = ctxMenu;
32
+ }
33
+ },
34
+ });
35
+ },
36
+ });
ComfyUI/web/extensions/core/keybinds.js ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {app} from "../../scripts/app.js";
2
+
3
+ app.registerExtension({
4
+ name: "Comfy.Keybinds",
5
+ init() {
6
+ const keybindListener = function (event) {
7
+ const modifierPressed = event.ctrlKey || event.metaKey;
8
+
9
+ // Queue prompt using ctrl or command + enter
10
+ if (modifierPressed && event.key === "Enter") {
11
+ app.queuePrompt(event.shiftKey ? -1 : 0).then();
12
+ return;
13
+ }
14
+
15
+ const target = event.composedPath()[0];
16
+ if (["INPUT", "TEXTAREA"].includes(target.tagName)) {
17
+ return;
18
+ }
19
+
20
+ const modifierKeyIdMap = {
21
+ s: "#comfy-save-button",
22
+ o: "#comfy-file-input",
23
+ Backspace: "#comfy-clear-button",
24
+ d: "#comfy-load-default-button",
25
+ };
26
+
27
+ const modifierKeybindId = modifierKeyIdMap[event.key];
28
+ if (modifierPressed && modifierKeybindId) {
29
+ event.preventDefault();
30
+
31
+ const elem = document.querySelector(modifierKeybindId);
32
+ elem.click();
33
+ return;
34
+ }
35
+
36
+ // Finished Handling all modifier keybinds, now handle the rest
37
+ if (event.ctrlKey || event.altKey || event.metaKey) {
38
+ return;
39
+ }
40
+
41
+ // Close out of modals using escape
42
+ if (event.key === "Escape") {
43
+ const modals = document.querySelectorAll(".comfy-modal");
44
+ const modal = Array.from(modals).find(modal => window.getComputedStyle(modal).getPropertyValue("display") !== "none");
45
+ if (modal) {
46
+ modal.style.display = "none";
47
+ }
48
+
49
+ [...document.querySelectorAll("dialog")].forEach(d => {
50
+ d.close();
51
+ });
52
+ }
53
+
54
+ const keyIdMap = {
55
+ q: "#comfy-view-queue-button",
56
+ h: "#comfy-view-history-button",
57
+ r: "#comfy-refresh-button",
58
+ };
59
+
60
+ const buttonId = keyIdMap[event.key];
61
+ if (buttonId) {
62
+ const button = document.querySelector(buttonId);
63
+ button.click();
64
+ }
65
+ }
66
+
67
+ window.addEventListener("keydown", keybindListener, true);
68
+ }
69
+ });
ComfyUI/web/extensions/core/linkRenderMode.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ const id = "Comfy.LinkRenderMode";
4
+ const ext = {
5
+ name: id,
6
+ async setup(app) {
7
+ app.ui.settings.addSetting({
8
+ id,
9
+ name: "Link Render Mode",
10
+ defaultValue: 2,
11
+ type: "combo",
12
+ options: [...LiteGraph.LINK_RENDER_MODES, "Hidden"].map((m, i) => ({
13
+ value: i,
14
+ text: m,
15
+ selected: i == app.canvas.links_render_mode,
16
+ })),
17
+ onChange(value) {
18
+ app.canvas.links_render_mode = +value;
19
+ app.graph.setDirtyCanvas(true);
20
+ },
21
+ });
22
+ },
23
+ };
24
+
25
+ app.registerExtension(ext);
ComfyUI/web/extensions/core/maskeditor.js ADDED
@@ -0,0 +1,967 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { ComfyDialog, $el } from "../../scripts/ui.js";
3
+ import { ComfyApp } from "../../scripts/app.js";
4
+ import { api } from "../../scripts/api.js"
5
+ import { ClipspaceDialog } from "./clipspace.js";
6
+
7
+ // Helper function to convert a data URL to a Blob object
8
+ function dataURLToBlob(dataURL) {
9
+ const parts = dataURL.split(';base64,');
10
+ const contentType = parts[0].split(':')[1];
11
+ const byteString = atob(parts[1]);
12
+ const arrayBuffer = new ArrayBuffer(byteString.length);
13
+ const uint8Array = new Uint8Array(arrayBuffer);
14
+ for (let i = 0; i < byteString.length; i++) {
15
+ uint8Array[i] = byteString.charCodeAt(i);
16
+ }
17
+ return new Blob([arrayBuffer], { type: contentType });
18
+ }
19
+
20
+ function loadedImageToBlob(image) {
21
+ const canvas = document.createElement('canvas');
22
+
23
+ canvas.width = image.width;
24
+ canvas.height = image.height;
25
+
26
+ const ctx = canvas.getContext('2d');
27
+
28
+ ctx.drawImage(image, 0, 0);
29
+
30
+ const dataURL = canvas.toDataURL('image/png', 1);
31
+ const blob = dataURLToBlob(dataURL);
32
+
33
+ return blob;
34
+ }
35
+
36
+ function loadImage(imagePath) {
37
+ return new Promise((resolve, reject) => {
38
+ const image = new Image();
39
+
40
+ image.onload = function() {
41
+ resolve(image);
42
+ };
43
+
44
+ image.src = imagePath;
45
+ });
46
+ }
47
+
48
+ async function uploadMask(filepath, formData) {
49
+ await api.fetchApi('/upload/mask', {
50
+ method: 'POST',
51
+ body: formData
52
+ }).then(response => {}).catch(error => {
53
+ console.error('Error:', error);
54
+ });
55
+
56
+ ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']] = new Image();
57
+ ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src = api.apiURL("/view?" + new URLSearchParams(filepath).toString() + app.getPreviewFormatParam() + app.getRandParam());
58
+
59
+ if(ComfyApp.clipspace.images)
60
+ ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']] = filepath;
61
+
62
+ ClipspaceDialog.invalidatePreview();
63
+ }
64
+
65
+ function prepare_mask(image, maskCanvas, maskCtx, maskColor) {
66
+ // paste mask data into alpha channel
67
+ maskCtx.drawImage(image, 0, 0, maskCanvas.width, maskCanvas.height);
68
+ const maskData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
69
+
70
+ // invert mask
71
+ for (let i = 0; i < maskData.data.length; i += 4) {
72
+ if(maskData.data[i+3] == 255)
73
+ maskData.data[i+3] = 0;
74
+ else
75
+ maskData.data[i+3] = 255;
76
+
77
+ maskData.data[i] = maskColor.r;
78
+ maskData.data[i+1] = maskColor.g;
79
+ maskData.data[i+2] = maskColor.b;
80
+ }
81
+
82
+ maskCtx.globalCompositeOperation = 'source-over';
83
+ maskCtx.putImageData(maskData, 0, 0);
84
+ }
85
+
86
+ class MaskEditorDialog extends ComfyDialog {
87
+ static instance = null;
88
+
89
+ static getInstance() {
90
+ if(!MaskEditorDialog.instance) {
91
+ MaskEditorDialog.instance = new MaskEditorDialog(app);
92
+ }
93
+
94
+ return MaskEditorDialog.instance;
95
+ }
96
+
97
+ is_layout_created = false;
98
+
99
+ constructor() {
100
+ super();
101
+ this.element = $el("div.comfy-modal", { parent: document.body },
102
+ [ $el("div.comfy-modal-content",
103
+ [...this.createButtons()]),
104
+ ]);
105
+ }
106
+
107
+ createButtons() {
108
+ return [];
109
+ }
110
+
111
+ createButton(name, callback) {
112
+ var button = document.createElement("button");
113
+ button.style.pointerEvents = "auto";
114
+ button.innerText = name;
115
+ button.addEventListener("click", callback);
116
+ return button;
117
+ }
118
+
119
+ createLeftButton(name, callback) {
120
+ var button = this.createButton(name, callback);
121
+ button.style.cssFloat = "left";
122
+ button.style.marginRight = "4px";
123
+ return button;
124
+ }
125
+
126
+ createRightButton(name, callback) {
127
+ var button = this.createButton(name, callback);
128
+ button.style.cssFloat = "right";
129
+ button.style.marginLeft = "4px";
130
+ return button;
131
+ }
132
+
133
+ createLeftSlider(self, name, callback) {
134
+ const divElement = document.createElement('div');
135
+ divElement.id = "maskeditor-slider";
136
+ divElement.style.cssFloat = "left";
137
+ divElement.style.fontFamily = "sans-serif";
138
+ divElement.style.marginRight = "4px";
139
+ divElement.style.color = "var(--input-text)";
140
+ divElement.style.backgroundColor = "var(--comfy-input-bg)";
141
+ divElement.style.borderRadius = "8px";
142
+ divElement.style.borderColor = "var(--border-color)";
143
+ divElement.style.borderStyle = "solid";
144
+ divElement.style.fontSize = "15px";
145
+ divElement.style.height = "21px";
146
+ divElement.style.padding = "1px 6px";
147
+ divElement.style.display = "flex";
148
+ divElement.style.position = "relative";
149
+ divElement.style.top = "2px";
150
+ divElement.style.pointerEvents = "auto";
151
+ self.brush_slider_input = document.createElement('input');
152
+ self.brush_slider_input.setAttribute('type', 'range');
153
+ self.brush_slider_input.setAttribute('min', '1');
154
+ self.brush_slider_input.setAttribute('max', '100');
155
+ self.brush_slider_input.setAttribute('value', '10');
156
+ const labelElement = document.createElement("label");
157
+ labelElement.textContent = name;
158
+
159
+ divElement.appendChild(labelElement);
160
+ divElement.appendChild(self.brush_slider_input);
161
+
162
+ self.brush_slider_input.addEventListener("change", callback);
163
+
164
+ return divElement;
165
+ }
166
+
167
+ createOpacitySlider(self, name, callback) {
168
+ const divElement = document.createElement('div');
169
+ divElement.id = "maskeditor-opacity-slider";
170
+ divElement.style.cssFloat = "left";
171
+ divElement.style.fontFamily = "sans-serif";
172
+ divElement.style.marginRight = "4px";
173
+ divElement.style.color = "var(--input-text)";
174
+ divElement.style.backgroundColor = "var(--comfy-input-bg)";
175
+ divElement.style.borderRadius = "8px";
176
+ divElement.style.borderColor = "var(--border-color)";
177
+ divElement.style.borderStyle = "solid";
178
+ divElement.style.fontSize = "15px";
179
+ divElement.style.height = "21px";
180
+ divElement.style.padding = "1px 6px";
181
+ divElement.style.display = "flex";
182
+ divElement.style.position = "relative";
183
+ divElement.style.top = "2px";
184
+ divElement.style.pointerEvents = "auto";
185
+ self.opacity_slider_input = document.createElement('input');
186
+ self.opacity_slider_input.setAttribute('type', 'range');
187
+ self.opacity_slider_input.setAttribute('min', '0.1');
188
+ self.opacity_slider_input.setAttribute('max', '1.0');
189
+ self.opacity_slider_input.setAttribute('step', '0.01')
190
+ self.opacity_slider_input.setAttribute('value', '0.7');
191
+ const labelElement = document.createElement("label");
192
+ labelElement.textContent = name;
193
+
194
+ divElement.appendChild(labelElement);
195
+ divElement.appendChild(self.opacity_slider_input);
196
+
197
+ self.opacity_slider_input.addEventListener("input", callback);
198
+
199
+ return divElement;
200
+ }
201
+
202
+ setlayout(imgCanvas, maskCanvas) {
203
+ const self = this;
204
+
205
+ // If it is specified as relative, using it only as a hidden placeholder for padding is recommended
206
+ // to prevent anomalies where it exceeds a certain size and goes outside of the window.
207
+ var bottom_panel = document.createElement("div");
208
+ bottom_panel.style.position = "absolute";
209
+ bottom_panel.style.bottom = "0px";
210
+ bottom_panel.style.left = "20px";
211
+ bottom_panel.style.right = "20px";
212
+ bottom_panel.style.height = "50px";
213
+ bottom_panel.style.pointerEvents = "none";
214
+
215
+ var brush = document.createElement("div");
216
+ brush.id = "brush";
217
+ brush.style.backgroundColor = "transparent";
218
+ brush.style.outline = "1px dashed black";
219
+ brush.style.boxShadow = "0 0 0 1px white";
220
+ brush.style.borderRadius = "50%";
221
+ brush.style.MozBorderRadius = "50%";
222
+ brush.style.WebkitBorderRadius = "50%";
223
+ brush.style.position = "absolute";
224
+ brush.style.zIndex = 8889;
225
+ brush.style.pointerEvents = "none";
226
+ this.brush = brush;
227
+ this.element.appendChild(imgCanvas);
228
+ this.element.appendChild(maskCanvas);
229
+ this.element.appendChild(bottom_panel);
230
+ document.body.appendChild(brush);
231
+
232
+ var clearButton = this.createLeftButton("Clear", () => {
233
+ self.maskCtx.clearRect(0, 0, self.maskCanvas.width, self.maskCanvas.height);
234
+ });
235
+
236
+ this.brush_size_slider = this.createLeftSlider(self, "Thickness", (event) => {
237
+ self.brush_size = event.target.value;
238
+ self.updateBrushPreview(self, null, null);
239
+ });
240
+
241
+ this.brush_opacity_slider = this.createOpacitySlider(self, "Opacity", (event) => {
242
+ self.brush_opacity = event.target.value;
243
+ if (self.brush_color_mode !== "negative") {
244
+ self.maskCanvas.style.opacity = self.brush_opacity;
245
+ }
246
+ });
247
+
248
+ this.colorButton = this.createLeftButton(this.getColorButtonText(), () => {
249
+ if (self.brush_color_mode === "black") {
250
+ self.brush_color_mode = "white";
251
+ }
252
+ else if (self.brush_color_mode === "white") {
253
+ self.brush_color_mode = "negative";
254
+ }
255
+ else {
256
+ self.brush_color_mode = "black";
257
+ }
258
+
259
+ self.updateWhenBrushColorModeChanged();
260
+ });
261
+
262
+ var cancelButton = this.createRightButton("Cancel", () => {
263
+ document.removeEventListener("mouseup", MaskEditorDialog.handleMouseUp);
264
+ document.removeEventListener("keydown", MaskEditorDialog.handleKeyDown);
265
+ self.close();
266
+ });
267
+
268
+ this.saveButton = this.createRightButton("Save", () => {
269
+ document.removeEventListener("mouseup", MaskEditorDialog.handleMouseUp);
270
+ document.removeEventListener("keydown", MaskEditorDialog.handleKeyDown);
271
+ self.save();
272
+ });
273
+
274
+ this.element.appendChild(imgCanvas);
275
+ this.element.appendChild(maskCanvas);
276
+ this.element.appendChild(bottom_panel);
277
+
278
+ bottom_panel.appendChild(clearButton);
279
+ bottom_panel.appendChild(this.saveButton);
280
+ bottom_panel.appendChild(cancelButton);
281
+ bottom_panel.appendChild(this.brush_size_slider);
282
+ bottom_panel.appendChild(this.brush_opacity_slider);
283
+ bottom_panel.appendChild(this.colorButton);
284
+
285
+ imgCanvas.style.position = "absolute";
286
+ maskCanvas.style.position = "absolute";
287
+
288
+ imgCanvas.style.top = "200";
289
+ imgCanvas.style.left = "0";
290
+
291
+ maskCanvas.style.top = imgCanvas.style.top;
292
+ maskCanvas.style.left = imgCanvas.style.left;
293
+
294
+ const maskCanvasStyle = this.getMaskCanvasStyle();
295
+ maskCanvas.style.mixBlendMode = maskCanvasStyle.mixBlendMode;
296
+ maskCanvas.style.opacity = maskCanvasStyle.opacity;
297
+ }
298
+
299
+ async show() {
300
+ this.zoom_ratio = 1.0;
301
+ this.pan_x = 0;
302
+ this.pan_y = 0;
303
+
304
+ if(!this.is_layout_created) {
305
+ // layout
306
+ const imgCanvas = document.createElement('canvas');
307
+ const maskCanvas = document.createElement('canvas');
308
+
309
+ imgCanvas.id = "imageCanvas";
310
+ maskCanvas.id = "maskCanvas";
311
+
312
+ this.setlayout(imgCanvas, maskCanvas);
313
+
314
+ // prepare content
315
+ this.imgCanvas = imgCanvas;
316
+ this.maskCanvas = maskCanvas;
317
+ this.maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true });
318
+
319
+ this.setEventHandler(maskCanvas);
320
+
321
+ this.is_layout_created = true;
322
+
323
+ // replacement of onClose hook since close is not real close
324
+ const self = this;
325
+ const observer = new MutationObserver(function(mutations) {
326
+ mutations.forEach(function(mutation) {
327
+ if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
328
+ if(self.last_display_style && self.last_display_style != 'none' && self.element.style.display == 'none') {
329
+ document.removeEventListener("mouseup", MaskEditorDialog.handleMouseUp);
330
+ self.brush.style.display = "none";
331
+ ComfyApp.onClipspaceEditorClosed();
332
+ }
333
+
334
+ self.last_display_style = self.element.style.display;
335
+ }
336
+ });
337
+ });
338
+
339
+ const config = { attributes: true };
340
+ observer.observe(this.element, config);
341
+ }
342
+
343
+ // The keydown event needs to be reconfigured when closing the dialog as it gets removed.
344
+ document.addEventListener('keydown', MaskEditorDialog.handleKeyDown);
345
+
346
+ if(ComfyApp.clipspace_return_node) {
347
+ this.saveButton.innerText = "Save to node";
348
+ }
349
+ else {
350
+ this.saveButton.innerText = "Save";
351
+ }
352
+ this.saveButton.disabled = false;
353
+
354
+ this.element.style.display = "block";
355
+ this.element.style.width = "85%";
356
+ this.element.style.margin = "0 7.5%";
357
+ this.element.style.height = "100vh";
358
+ this.element.style.top = "50%";
359
+ this.element.style.left = "42%";
360
+ this.element.style.zIndex = 8888; // NOTE: alert dialog must be high priority.
361
+
362
+ await this.setImages(this.imgCanvas);
363
+
364
+ this.is_visible = true;
365
+ }
366
+
367
+ isOpened() {
368
+ return this.element.style.display == "block";
369
+ }
370
+
371
+ invalidateCanvas(orig_image, mask_image) {
372
+ this.imgCanvas.width = orig_image.width;
373
+ this.imgCanvas.height = orig_image.height;
374
+
375
+ this.maskCanvas.width = orig_image.width;
376
+ this.maskCanvas.height = orig_image.height;
377
+
378
+ let imgCtx = this.imgCanvas.getContext('2d', {willReadFrequently: true });
379
+ let maskCtx = this.maskCanvas.getContext('2d', {willReadFrequently: true });
380
+
381
+ imgCtx.drawImage(orig_image, 0, 0, orig_image.width, orig_image.height);
382
+ prepare_mask(mask_image, this.maskCanvas, maskCtx, this.getMaskColor());
383
+ }
384
+
385
+ async setImages(imgCanvas) {
386
+ let self = this;
387
+
388
+ const imgCtx = imgCanvas.getContext('2d', {willReadFrequently: true });
389
+ const maskCtx = this.maskCtx;
390
+ const maskCanvas = this.maskCanvas;
391
+
392
+ imgCtx.clearRect(0,0,this.imgCanvas.width,this.imgCanvas.height);
393
+ maskCtx.clearRect(0,0,this.maskCanvas.width,this.maskCanvas.height);
394
+
395
+ // image load
396
+ const filepath = ComfyApp.clipspace.images;
397
+
398
+ const alpha_url = new URL(ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src)
399
+ alpha_url.searchParams.delete('channel');
400
+ alpha_url.searchParams.delete('preview');
401
+ alpha_url.searchParams.set('channel', 'a');
402
+ let mask_image = await loadImage(alpha_url);
403
+
404
+ // original image load
405
+ const rgb_url = new URL(ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src);
406
+ rgb_url.searchParams.delete('channel');
407
+ rgb_url.searchParams.set('channel', 'rgb');
408
+ this.image = new Image();
409
+ this.image.onload = function() {
410
+ maskCanvas.width = self.image.width;
411
+ maskCanvas.height = self.image.height;
412
+
413
+ self.invalidateCanvas(self.image, mask_image);
414
+ self.initializeCanvasPanZoom();
415
+ };
416
+ this.image.src = rgb_url;
417
+ }
418
+
419
+ initializeCanvasPanZoom() {
420
+ // set initialize
421
+ let drawWidth = this.image.width;
422
+ let drawHeight = this.image.height;
423
+
424
+ let width = this.element.clientWidth;
425
+ let height = this.element.clientHeight;
426
+
427
+ if (this.image.width > width) {
428
+ drawWidth = width;
429
+ drawHeight = (drawWidth / this.image.width) * this.image.height;
430
+ }
431
+
432
+ if (drawHeight > height) {
433
+ drawHeight = height;
434
+ drawWidth = (drawHeight / this.image.height) * this.image.width;
435
+ }
436
+
437
+ this.zoom_ratio = drawWidth/this.image.width;
438
+
439
+ const canvasX = (width - drawWidth) / 2;
440
+ const canvasY = (height - drawHeight) / 2;
441
+ this.pan_x = canvasX;
442
+ this.pan_y = canvasY;
443
+
444
+ this.invalidatePanZoom();
445
+ }
446
+
447
+
448
+ invalidatePanZoom() {
449
+ let raw_width = this.image.width * this.zoom_ratio;
450
+ let raw_height = this.image.height * this.zoom_ratio;
451
+
452
+ if(this.pan_x + raw_width < 10) {
453
+ this.pan_x = 10 - raw_width;
454
+ }
455
+
456
+ if(this.pan_y + raw_height < 10) {
457
+ this.pan_y = 10 - raw_height;
458
+ }
459
+
460
+ let width = `${raw_width}px`;
461
+ let height = `${raw_height}px`;
462
+
463
+ let left = `${this.pan_x}px`;
464
+ let top = `${this.pan_y}px`;
465
+
466
+ this.maskCanvas.style.width = width;
467
+ this.maskCanvas.style.height = height;
468
+ this.maskCanvas.style.left = left;
469
+ this.maskCanvas.style.top = top;
470
+
471
+ this.imgCanvas.style.width = width;
472
+ this.imgCanvas.style.height = height;
473
+ this.imgCanvas.style.left = left;
474
+ this.imgCanvas.style.top = top;
475
+ }
476
+
477
+
478
+ setEventHandler(maskCanvas) {
479
+ const self = this;
480
+
481
+ if(!this.handler_registered) {
482
+ maskCanvas.addEventListener("contextmenu", (event) => {
483
+ event.preventDefault();
484
+ });
485
+
486
+ this.element.addEventListener('wheel', (event) => this.handleWheelEvent(self,event));
487
+ this.element.addEventListener('pointermove', (event) => this.pointMoveEvent(self,event));
488
+ this.element.addEventListener('touchmove', (event) => this.pointMoveEvent(self,event));
489
+
490
+ this.element.addEventListener('dragstart', (event) => {
491
+ if(event.ctrlKey) {
492
+ event.preventDefault();
493
+ }
494
+ });
495
+
496
+ maskCanvas.addEventListener('pointerdown', (event) => this.handlePointerDown(self,event));
497
+ maskCanvas.addEventListener('pointermove', (event) => this.draw_move(self,event));
498
+ maskCanvas.addEventListener('touchmove', (event) => this.draw_move(self,event));
499
+ maskCanvas.addEventListener('pointerover', (event) => { this.brush.style.display = "block"; });
500
+ maskCanvas.addEventListener('pointerleave', (event) => { this.brush.style.display = "none"; });
501
+
502
+ document.addEventListener('pointerup', MaskEditorDialog.handlePointerUp);
503
+
504
+ this.handler_registered = true;
505
+ }
506
+ }
507
+
508
+ getMaskCanvasStyle() {
509
+ if (this.brush_color_mode === "negative") {
510
+ return {
511
+ mixBlendMode: "difference",
512
+ opacity: "1",
513
+ };
514
+ }
515
+ else {
516
+ return {
517
+ mixBlendMode: "initial",
518
+ opacity: this.brush_opacity,
519
+ };
520
+ }
521
+ }
522
+
523
+ getMaskColor() {
524
+ if (this.brush_color_mode === "black") {
525
+ return { r: 0, g: 0, b: 0 };
526
+ }
527
+ if (this.brush_color_mode === "white") {
528
+ return { r: 255, g: 255, b: 255 };
529
+ }
530
+ if (this.brush_color_mode === "negative") {
531
+ // negative effect only works with white color
532
+ return { r: 255, g: 255, b: 255 };
533
+ }
534
+
535
+ return { r: 0, g: 0, b: 0 };
536
+ }
537
+
538
+ getMaskFillStyle() {
539
+ const maskColor = this.getMaskColor();
540
+
541
+ return "rgb(" + maskColor.r + "," + maskColor.g + "," + maskColor.b + ")";
542
+ }
543
+
544
+ getColorButtonText() {
545
+ let colorCaption = "unknown";
546
+
547
+ if (this.brush_color_mode === "black") {
548
+ colorCaption = "black";
549
+ }
550
+ else if (this.brush_color_mode === "white") {
551
+ colorCaption = "white";
552
+ }
553
+ else if (this.brush_color_mode === "negative") {
554
+ colorCaption = "negative";
555
+ }
556
+
557
+ return "Color: " + colorCaption;
558
+ }
559
+
560
+ updateWhenBrushColorModeChanged() {
561
+ this.colorButton.innerText = this.getColorButtonText();
562
+
563
+ // update mask canvas css styles
564
+
565
+ const maskCanvasStyle = this.getMaskCanvasStyle();
566
+ this.maskCanvas.style.mixBlendMode = maskCanvasStyle.mixBlendMode;
567
+ this.maskCanvas.style.opacity = maskCanvasStyle.opacity;
568
+
569
+ // update mask canvas rgb colors
570
+
571
+ const maskColor = this.getMaskColor();
572
+
573
+ const maskData = this.maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
574
+
575
+ for (let i = 0; i < maskData.data.length; i += 4) {
576
+ maskData.data[i] = maskColor.r;
577
+ maskData.data[i+1] = maskColor.g;
578
+ maskData.data[i+2] = maskColor.b;
579
+ }
580
+
581
+ this.maskCtx.putImageData(maskData, 0, 0);
582
+ }
583
+
584
+ brush_opacity = 0.7;
585
+ brush_size = 10;
586
+ brush_color_mode = "black";
587
+ drawing_mode = false;
588
+ lastx = -1;
589
+ lasty = -1;
590
+ lasttime = 0;
591
+
592
+ static handleKeyDown(event) {
593
+ const self = MaskEditorDialog.instance;
594
+ if (event.key === ']') {
595
+ self.brush_size = Math.min(self.brush_size+2, 100);
596
+ self.brush_slider_input.value = self.brush_size;
597
+ } else if (event.key === '[') {
598
+ self.brush_size = Math.max(self.brush_size-2, 1);
599
+ self.brush_slider_input.value = self.brush_size;
600
+ } else if(event.key === 'Enter') {
601
+ self.save();
602
+ }
603
+
604
+ self.updateBrushPreview(self);
605
+ }
606
+
607
+ static handlePointerUp(event) {
608
+ event.preventDefault();
609
+
610
+ this.mousedown_x = null;
611
+ this.mousedown_y = null;
612
+
613
+ MaskEditorDialog.instance.drawing_mode = false;
614
+ }
615
+
616
+ updateBrushPreview(self) {
617
+ const brush = self.brush;
618
+
619
+ var centerX = self.cursorX;
620
+ var centerY = self.cursorY;
621
+
622
+ brush.style.width = self.brush_size * 2 * this.zoom_ratio + "px";
623
+ brush.style.height = self.brush_size * 2 * this.zoom_ratio + "px";
624
+ brush.style.left = (centerX - self.brush_size * this.zoom_ratio) + "px";
625
+ brush.style.top = (centerY - self.brush_size * this.zoom_ratio) + "px";
626
+ }
627
+
628
+ handleWheelEvent(self, event) {
629
+ event.preventDefault();
630
+
631
+ if(event.ctrlKey) {
632
+ // zoom canvas
633
+ if(event.deltaY < 0) {
634
+ this.zoom_ratio = Math.min(10.0, this.zoom_ratio+0.2);
635
+ }
636
+ else {
637
+ this.zoom_ratio = Math.max(0.2, this.zoom_ratio-0.2);
638
+ }
639
+
640
+ this.invalidatePanZoom();
641
+ }
642
+ else {
643
+ // adjust brush size
644
+ if(event.deltaY < 0)
645
+ this.brush_size = Math.min(this.brush_size+2, 100);
646
+ else
647
+ this.brush_size = Math.max(this.brush_size-2, 1);
648
+
649
+ this.brush_slider_input.value = this.brush_size;
650
+
651
+ this.updateBrushPreview(this);
652
+ }
653
+ }
654
+
655
+ pointMoveEvent(self, event) {
656
+ this.cursorX = event.pageX;
657
+ this.cursorY = event.pageY;
658
+
659
+ self.updateBrushPreview(self);
660
+
661
+ if(event.ctrlKey) {
662
+ event.preventDefault();
663
+ self.pan_move(self, event);
664
+ }
665
+
666
+ let left_button_down = window.TouchEvent && event instanceof TouchEvent || event.buttons == 1;
667
+
668
+ if(event.shiftKey && left_button_down) {
669
+ self.drawing_mode = false;
670
+
671
+ const y = event.clientY;
672
+ let delta = (self.zoom_lasty - y)*0.005;
673
+ self.zoom_ratio = Math.max(Math.min(10.0, self.last_zoom_ratio - delta), 0.2);
674
+
675
+ this.invalidatePanZoom();
676
+ return;
677
+ }
678
+ }
679
+
680
+ pan_move(self, event) {
681
+ if(event.buttons == 1) {
682
+ if(this.mousedown_x) {
683
+ let deltaX = this.mousedown_x - event.clientX;
684
+ let deltaY = this.mousedown_y - event.clientY;
685
+
686
+ self.pan_x = this.mousedown_pan_x - deltaX;
687
+ self.pan_y = this.mousedown_pan_y - deltaY;
688
+
689
+ self.invalidatePanZoom();
690
+ }
691
+ }
692
+ }
693
+
694
+ draw_move(self, event) {
695
+ if(event.ctrlKey || event.shiftKey) {
696
+ return;
697
+ }
698
+
699
+ event.preventDefault();
700
+
701
+ this.cursorX = event.pageX;
702
+ this.cursorY = event.pageY;
703
+
704
+ self.updateBrushPreview(self);
705
+
706
+ let left_button_down = window.TouchEvent && event instanceof TouchEvent || event.buttons == 1;
707
+ let right_button_down = [2, 5, 32].includes(event.buttons);
708
+
709
+ if (!event.altKey && left_button_down) {
710
+ var diff = performance.now() - self.lasttime;
711
+
712
+ const maskRect = self.maskCanvas.getBoundingClientRect();
713
+
714
+ var x = event.offsetX;
715
+ var y = event.offsetY
716
+
717
+ if(event.offsetX == null) {
718
+ x = event.targetTouches[0].clientX - maskRect.left;
719
+ }
720
+
721
+ if(event.offsetY == null) {
722
+ y = event.targetTouches[0].clientY - maskRect.top;
723
+ }
724
+
725
+ x /= self.zoom_ratio;
726
+ y /= self.zoom_ratio;
727
+
728
+ var brush_size = this.brush_size;
729
+ if(event instanceof PointerEvent && event.pointerType == 'pen') {
730
+ brush_size *= event.pressure;
731
+ this.last_pressure = event.pressure;
732
+ }
733
+ else if(window.TouchEvent && event instanceof TouchEvent && diff < 20){
734
+ // The firing interval of PointerEvents in Pen is unreliable, so it is supplemented by TouchEvents.
735
+ brush_size *= this.last_pressure;
736
+ }
737
+ else {
738
+ brush_size = this.brush_size;
739
+ }
740
+
741
+ if(diff > 20 && !this.drawing_mode)
742
+ requestAnimationFrame(() => {
743
+ self.maskCtx.beginPath();
744
+ self.maskCtx.fillStyle = this.getMaskFillStyle();
745
+ self.maskCtx.globalCompositeOperation = "source-over";
746
+ self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false);
747
+ self.maskCtx.fill();
748
+ self.lastx = x;
749
+ self.lasty = y;
750
+ });
751
+ else
752
+ requestAnimationFrame(() => {
753
+ self.maskCtx.beginPath();
754
+ self.maskCtx.fillStyle = this.getMaskFillStyle();
755
+ self.maskCtx.globalCompositeOperation = "source-over";
756
+
757
+ var dx = x - self.lastx;
758
+ var dy = y - self.lasty;
759
+
760
+ var distance = Math.sqrt(dx * dx + dy * dy);
761
+ var directionX = dx / distance;
762
+ var directionY = dy / distance;
763
+
764
+ for (var i = 0; i < distance; i+=5) {
765
+ var px = self.lastx + (directionX * i);
766
+ var py = self.lasty + (directionY * i);
767
+ self.maskCtx.arc(px, py, brush_size, 0, Math.PI * 2, false);
768
+ self.maskCtx.fill();
769
+ }
770
+ self.lastx = x;
771
+ self.lasty = y;
772
+ });
773
+
774
+ self.lasttime = performance.now();
775
+ }
776
+ else if((event.altKey && left_button_down) || right_button_down) {
777
+ const maskRect = self.maskCanvas.getBoundingClientRect();
778
+ const x = (event.offsetX || event.targetTouches[0].clientX - maskRect.left) / self.zoom_ratio;
779
+ const y = (event.offsetY || event.targetTouches[0].clientY - maskRect.top) / self.zoom_ratio;
780
+
781
+ var brush_size = this.brush_size;
782
+ if(event instanceof PointerEvent && event.pointerType == 'pen') {
783
+ brush_size *= event.pressure;
784
+ this.last_pressure = event.pressure;
785
+ }
786
+ else if(window.TouchEvent && event instanceof TouchEvent && diff < 20){
787
+ brush_size *= this.last_pressure;
788
+ }
789
+ else {
790
+ brush_size = this.brush_size;
791
+ }
792
+
793
+ if(diff > 20 && !drawing_mode) // cannot tracking drawing_mode for touch event
794
+ requestAnimationFrame(() => {
795
+ self.maskCtx.beginPath();
796
+ self.maskCtx.globalCompositeOperation = "destination-out";
797
+ self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false);
798
+ self.maskCtx.fill();
799
+ self.lastx = x;
800
+ self.lasty = y;
801
+ });
802
+ else
803
+ requestAnimationFrame(() => {
804
+ self.maskCtx.beginPath();
805
+ self.maskCtx.globalCompositeOperation = "destination-out";
806
+
807
+ var dx = x - self.lastx;
808
+ var dy = y - self.lasty;
809
+
810
+ var distance = Math.sqrt(dx * dx + dy * dy);
811
+ var directionX = dx / distance;
812
+ var directionY = dy / distance;
813
+
814
+ for (var i = 0; i < distance; i+=5) {
815
+ var px = self.lastx + (directionX * i);
816
+ var py = self.lasty + (directionY * i);
817
+ self.maskCtx.arc(px, py, brush_size, 0, Math.PI * 2, false);
818
+ self.maskCtx.fill();
819
+ }
820
+ self.lastx = x;
821
+ self.lasty = y;
822
+ });
823
+
824
+ self.lasttime = performance.now();
825
+ }
826
+ }
827
+
828
+ handlePointerDown(self, event) {
829
+ if(event.ctrlKey) {
830
+ if (event.buttons == 1) {
831
+ this.mousedown_x = event.clientX;
832
+ this.mousedown_y = event.clientY;
833
+
834
+ this.mousedown_pan_x = this.pan_x;
835
+ this.mousedown_pan_y = this.pan_y;
836
+ }
837
+ return;
838
+ }
839
+
840
+ var brush_size = this.brush_size;
841
+ if(event instanceof PointerEvent && event.pointerType == 'pen') {
842
+ brush_size *= event.pressure;
843
+ this.last_pressure = event.pressure;
844
+ }
845
+
846
+ if ([0, 2, 5].includes(event.button)) {
847
+ self.drawing_mode = true;
848
+
849
+ event.preventDefault();
850
+
851
+ if(event.shiftKey) {
852
+ self.zoom_lasty = event.clientY;
853
+ self.last_zoom_ratio = self.zoom_ratio;
854
+ return;
855
+ }
856
+
857
+ const maskRect = self.maskCanvas.getBoundingClientRect();
858
+ const x = (event.offsetX || event.targetTouches[0].clientX - maskRect.left) / self.zoom_ratio;
859
+ const y = (event.offsetY || event.targetTouches[0].clientY - maskRect.top) / self.zoom_ratio;
860
+
861
+ self.maskCtx.beginPath();
862
+ if (!event.altKey && event.button == 0) {
863
+ self.maskCtx.fillStyle = this.getMaskFillStyle();
864
+ self.maskCtx.globalCompositeOperation = "source-over";
865
+ } else {
866
+ self.maskCtx.globalCompositeOperation = "destination-out";
867
+ }
868
+ self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false);
869
+ self.maskCtx.fill();
870
+ self.lastx = x;
871
+ self.lasty = y;
872
+ self.lasttime = performance.now();
873
+ }
874
+ }
875
+
876
+ async save() {
877
+ const backupCanvas = document.createElement('canvas');
878
+ const backupCtx = backupCanvas.getContext('2d', {willReadFrequently:true});
879
+ backupCanvas.width = this.image.width;
880
+ backupCanvas.height = this.image.height;
881
+
882
+ backupCtx.clearRect(0,0, backupCanvas.width, backupCanvas.height);
883
+ backupCtx.drawImage(this.maskCanvas,
884
+ 0, 0, this.maskCanvas.width, this.maskCanvas.height,
885
+ 0, 0, backupCanvas.width, backupCanvas.height);
886
+
887
+ // paste mask data into alpha channel
888
+ const backupData = backupCtx.getImageData(0, 0, backupCanvas.width, backupCanvas.height);
889
+
890
+ // refine mask image
891
+ for (let i = 0; i < backupData.data.length; i += 4) {
892
+ if(backupData.data[i+3] == 255)
893
+ backupData.data[i+3] = 0;
894
+ else
895
+ backupData.data[i+3] = 255;
896
+
897
+ backupData.data[i] = 0;
898
+ backupData.data[i+1] = 0;
899
+ backupData.data[i+2] = 0;
900
+ }
901
+
902
+ backupCtx.globalCompositeOperation = 'source-over';
903
+ backupCtx.putImageData(backupData, 0, 0);
904
+
905
+ const formData = new FormData();
906
+ const filename = "clipspace-mask-" + performance.now() + ".png";
907
+
908
+ const item =
909
+ {
910
+ "filename": filename,
911
+ "subfolder": "clipspace",
912
+ "type": "input",
913
+ };
914
+
915
+ if(ComfyApp.clipspace.images)
916
+ ComfyApp.clipspace.images[0] = item;
917
+
918
+ if(ComfyApp.clipspace.widgets) {
919
+ const index = ComfyApp.clipspace.widgets.findIndex(obj => obj.name === 'image');
920
+
921
+ if(index >= 0)
922
+ ComfyApp.clipspace.widgets[index].value = item;
923
+ }
924
+
925
+ const dataURL = backupCanvas.toDataURL();
926
+ const blob = dataURLToBlob(dataURL);
927
+
928
+ let original_url = new URL(this.image.src);
929
+
930
+ const original_ref = { filename: original_url.searchParams.get('filename') };
931
+
932
+ let original_subfolder = original_url.searchParams.get("subfolder");
933
+ if(original_subfolder)
934
+ original_ref.subfolder = original_subfolder;
935
+
936
+ let original_type = original_url.searchParams.get("type");
937
+ if(original_type)
938
+ original_ref.type = original_type;
939
+
940
+ formData.append('image', blob, filename);
941
+ formData.append('original_ref', JSON.stringify(original_ref));
942
+ formData.append('type', "input");
943
+ formData.append('subfolder', "clipspace");
944
+
945
+ this.saveButton.innerText = "Saving...";
946
+ this.saveButton.disabled = true;
947
+ await uploadMask(item, formData);
948
+ ComfyApp.onClipspaceEditorSave();
949
+ this.close();
950
+ }
951
+ }
952
+
953
+ app.registerExtension({
954
+ name: "Comfy.MaskEditor",
955
+ init(app) {
956
+ ComfyApp.open_maskeditor =
957
+ function () {
958
+ const dlg = MaskEditorDialog.getInstance();
959
+ if(!dlg.isOpened()) {
960
+ dlg.show();
961
+ }
962
+ };
963
+
964
+ const context_predicate = () => ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0
965
+ ClipspaceDialog.registerButton("MaskEditor", context_predicate, ComfyApp.open_maskeditor);
966
+ }
967
+ });
ComfyUI/web/extensions/core/nodeTemplates.js ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { api } from "../../scripts/api.js";
3
+ import { ComfyDialog, $el } from "../../scripts/ui.js";
4
+ import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
5
+
6
+ // Adds the ability to save and add multiple nodes as a template
7
+ // To save:
8
+ // Select multiple nodes (ctrl + drag to select a region or ctrl+click individual nodes)
9
+ // Right click the canvas
10
+ // Save Node Template -> give it a name
11
+ //
12
+ // To add:
13
+ // Right click the canvas
14
+ // Node templates -> click the one to add
15
+ //
16
+ // To delete/rename:
17
+ // Right click the canvas
18
+ // Node templates -> Manage
19
+ //
20
+ // To rearrange:
21
+ // Open the manage dialog and Drag and drop elements using the "Name:" label as handle
22
+
23
+ const id = "Comfy.NodeTemplates";
24
+ const file = "comfy.templates.json";
25
+
26
+ class ManageTemplates extends ComfyDialog {
27
+ constructor() {
28
+ super();
29
+ this.load().then((v) => {
30
+ this.templates = v;
31
+ });
32
+
33
+ this.element.classList.add("comfy-manage-templates");
34
+ this.draggedEl = null;
35
+ this.saveVisualCue = null;
36
+ this.emptyImg = new Image();
37
+ this.emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
38
+
39
+ this.importInput = $el("input", {
40
+ type: "file",
41
+ accept: ".json",
42
+ multiple: true,
43
+ style: { display: "none" },
44
+ parent: document.body,
45
+ onchange: () => this.importAll(),
46
+ });
47
+ }
48
+
49
+ createButtons() {
50
+ const btns = super.createButtons();
51
+ btns[0].textContent = "Close";
52
+ btns[0].onclick = (e) => {
53
+ clearTimeout(this.saveVisualCue);
54
+ this.close();
55
+ };
56
+ btns.unshift(
57
+ $el("button", {
58
+ type: "button",
59
+ textContent: "Export",
60
+ onclick: () => this.exportAll(),
61
+ })
62
+ );
63
+ btns.unshift(
64
+ $el("button", {
65
+ type: "button",
66
+ textContent: "Import",
67
+ onclick: () => {
68
+ this.importInput.click();
69
+ },
70
+ })
71
+ );
72
+ return btns;
73
+ }
74
+
75
+ async load() {
76
+ let templates = [];
77
+ if (app.storageLocation === "server") {
78
+ if (app.isNewUserSession) {
79
+ // New user so migrate existing templates
80
+ const json = localStorage.getItem(id);
81
+ if (json) {
82
+ templates = JSON.parse(json);
83
+ }
84
+ await api.storeUserData(file, json, { stringify: false });
85
+ } else {
86
+ const res = await api.getUserData(file);
87
+ if (res.status === 200) {
88
+ try {
89
+ templates = await res.json();
90
+ } catch (error) {
91
+ }
92
+ } else if (res.status !== 404) {
93
+ console.error(res.status + " " + res.statusText);
94
+ }
95
+ }
96
+ } else {
97
+ const json = localStorage.getItem(id);
98
+ if (json) {
99
+ templates = JSON.parse(json);
100
+ }
101
+ }
102
+
103
+ return templates ?? [];
104
+ }
105
+
106
+ async store() {
107
+ if(app.storageLocation === "server") {
108
+ const templates = JSON.stringify(this.templates, undefined, 4);
109
+ localStorage.setItem(id, templates); // Backwards compatibility
110
+ try {
111
+ await api.storeUserData(file, templates, { stringify: false });
112
+ } catch (error) {
113
+ console.error(error);
114
+ alert(error.message);
115
+ }
116
+ } else {
117
+ localStorage.setItem(id, JSON.stringify(this.templates));
118
+ }
119
+ }
120
+
121
+ async importAll() {
122
+ for (const file of this.importInput.files) {
123
+ if (file.type === "application/json" || file.name.endsWith(".json")) {
124
+ const reader = new FileReader();
125
+ reader.onload = async () => {
126
+ const importFile = JSON.parse(reader.result);
127
+ if (importFile?.templates) {
128
+ for (const template of importFile.templates) {
129
+ if (template?.name && template?.data) {
130
+ this.templates.push(template);
131
+ }
132
+ }
133
+ await this.store();
134
+ }
135
+ };
136
+ await reader.readAsText(file);
137
+ }
138
+ }
139
+
140
+ this.importInput.value = null;
141
+
142
+ this.close();
143
+ }
144
+
145
+ exportAll() {
146
+ if (this.templates.length == 0) {
147
+ alert("No templates to export.");
148
+ return;
149
+ }
150
+
151
+ const json = JSON.stringify({ templates: this.templates }, null, 2); // convert the data to a JSON string
152
+ const blob = new Blob([json], { type: "application/json" });
153
+ const url = URL.createObjectURL(blob);
154
+ const a = $el("a", {
155
+ href: url,
156
+ download: "node_templates.json",
157
+ style: { display: "none" },
158
+ parent: document.body,
159
+ });
160
+ a.click();
161
+ setTimeout(function () {
162
+ a.remove();
163
+ window.URL.revokeObjectURL(url);
164
+ }, 0);
165
+ }
166
+
167
+ show() {
168
+ // Show list of template names + delete button
169
+ super.show(
170
+ $el(
171
+ "div",
172
+ {},
173
+ this.templates.flatMap((t,i) => {
174
+ let nameInput;
175
+ return [
176
+ $el(
177
+ "div",
178
+ {
179
+ dataset: { id: i },
180
+ className: "tempateManagerRow",
181
+ style: {
182
+ display: "grid",
183
+ gridTemplateColumns: "1fr auto",
184
+ border: "1px dashed transparent",
185
+ gap: "5px",
186
+ backgroundColor: "var(--comfy-menu-bg)"
187
+ },
188
+ ondragstart: (e) => {
189
+ this.draggedEl = e.currentTarget;
190
+ e.currentTarget.style.opacity = "0.6";
191
+ e.currentTarget.style.border = "1px dashed yellow";
192
+ e.dataTransfer.effectAllowed = 'move';
193
+ e.dataTransfer.setDragImage(this.emptyImg, 0, 0);
194
+ },
195
+ ondragend: (e) => {
196
+ e.target.style.opacity = "1";
197
+ e.currentTarget.style.border = "1px dashed transparent";
198
+ e.currentTarget.removeAttribute("draggable");
199
+
200
+ // rearrange the elements
201
+ this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
202
+ var prev_i = el.dataset.id;
203
+
204
+ if ( el == this.draggedEl && prev_i != i ) {
205
+ this.templates.splice(i, 0, this.templates.splice(prev_i, 1)[0]);
206
+ }
207
+ el.dataset.id = i;
208
+ });
209
+ this.store();
210
+ },
211
+ ondragover: (e) => {
212
+ e.preventDefault();
213
+ if ( e.currentTarget == this.draggedEl )
214
+ return;
215
+
216
+ let rect = e.currentTarget.getBoundingClientRect();
217
+ if (e.clientY > rect.top + rect.height / 2) {
218
+ e.currentTarget.parentNode.insertBefore(this.draggedEl, e.currentTarget.nextSibling);
219
+ } else {
220
+ e.currentTarget.parentNode.insertBefore(this.draggedEl, e.currentTarget);
221
+ }
222
+ }
223
+ },
224
+ [
225
+ $el(
226
+ "label",
227
+ {
228
+ textContent: "Name: ",
229
+ style: {
230
+ cursor: "grab",
231
+ },
232
+ onmousedown: (e) => {
233
+ // enable dragging only from the label
234
+ if (e.target.localName == 'label')
235
+ e.currentTarget.parentNode.draggable = 'true';
236
+ }
237
+ },
238
+ [
239
+ $el("input", {
240
+ value: t.name,
241
+ dataset: { name: t.name },
242
+ style: {
243
+ transitionProperty: 'background-color',
244
+ transitionDuration: '0s',
245
+ },
246
+ onchange: (e) => {
247
+ clearTimeout(this.saveVisualCue);
248
+ var el = e.target;
249
+ var row = el.parentNode.parentNode;
250
+ this.templates[row.dataset.id].name = el.value.trim() || 'untitled';
251
+ this.store();
252
+ el.style.backgroundColor = 'rgb(40, 95, 40)';
253
+ el.style.transitionDuration = '0s';
254
+ this.saveVisualCue = setTimeout(function () {
255
+ el.style.transitionDuration = '.7s';
256
+ el.style.backgroundColor = 'var(--comfy-input-bg)';
257
+ }, 15);
258
+ },
259
+ onkeypress: (e) => {
260
+ var el = e.target;
261
+ clearTimeout(this.saveVisualCue);
262
+ el.style.transitionDuration = '0s';
263
+ el.style.backgroundColor = 'var(--comfy-input-bg)';
264
+ },
265
+ $: (el) => (nameInput = el),
266
+ })
267
+ ]
268
+ ),
269
+ $el(
270
+ "div",
271
+ {},
272
+ [
273
+ $el("button", {
274
+ textContent: "Export",
275
+ style: {
276
+ fontSize: "12px",
277
+ fontWeight: "normal",
278
+ },
279
+ onclick: (e) => {
280
+ const json = JSON.stringify({templates: [t]}, null, 2); // convert the data to a JSON string
281
+ const blob = new Blob([json], {type: "application/json"});
282
+ const url = URL.createObjectURL(blob);
283
+ const a = $el("a", {
284
+ href: url,
285
+ download: (nameInput.value || t.name) + ".json",
286
+ style: {display: "none"},
287
+ parent: document.body,
288
+ });
289
+ a.click();
290
+ setTimeout(function () {
291
+ a.remove();
292
+ window.URL.revokeObjectURL(url);
293
+ }, 0);
294
+ },
295
+ }),
296
+ $el("button", {
297
+ textContent: "Delete",
298
+ style: {
299
+ fontSize: "12px",
300
+ color: "red",
301
+ fontWeight: "normal",
302
+ },
303
+ onclick: (e) => {
304
+ const item = e.target.parentNode.parentNode;
305
+ item.parentNode.removeChild(item);
306
+ this.templates.splice(item.dataset.id*1, 1);
307
+ this.store();
308
+ // update the rows index, setTimeout ensures that the list is updated
309
+ var that = this;
310
+ setTimeout(function (){
311
+ that.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
312
+ el.dataset.id = i;
313
+ });
314
+ }, 0);
315
+ },
316
+ }),
317
+ ]
318
+ ),
319
+ ]
320
+ )
321
+ ];
322
+ })
323
+ )
324
+ );
325
+ }
326
+ }
327
+
328
+ app.registerExtension({
329
+ name: id,
330
+ setup() {
331
+ const manage = new ManageTemplates();
332
+
333
+ const clipboardAction = async (cb) => {
334
+ // We use the clipboard functions but dont want to overwrite the current user clipboard
335
+ // Restore it after we've run our callback
336
+ const old = localStorage.getItem("litegrapheditor_clipboard");
337
+ await cb();
338
+ localStorage.setItem("litegrapheditor_clipboard", old);
339
+ };
340
+
341
+ const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
342
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
343
+ const options = orig.apply(this, arguments);
344
+
345
+ options.push(null);
346
+ options.push({
347
+ content: `Save Selected as Template`,
348
+ disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
349
+ callback: () => {
350
+ const name = prompt("Enter name");
351
+ if (!name?.trim()) return;
352
+
353
+ clipboardAction(() => {
354
+ app.canvas.copyToClipboard();
355
+ let data = localStorage.getItem("litegrapheditor_clipboard");
356
+ data = JSON.parse(data);
357
+ const nodeIds = Object.keys(app.canvas.selected_nodes);
358
+ for (let i = 0; i < nodeIds.length; i++) {
359
+ const node = app.graph.getNodeById(nodeIds[i]);
360
+ const nodeData = node?.constructor.nodeData;
361
+
362
+ let groupData = GroupNodeHandler.getGroupData(node);
363
+ if (groupData) {
364
+ groupData = groupData.nodeData;
365
+ if (!data.groupNodes) {
366
+ data.groupNodes = {};
367
+ }
368
+ data.groupNodes[nodeData.name] = groupData;
369
+ data.nodes[i].type = nodeData.name;
370
+ }
371
+ }
372
+
373
+ manage.templates.push({
374
+ name,
375
+ data: JSON.stringify(data),
376
+ });
377
+ manage.store();
378
+ });
379
+ },
380
+ });
381
+
382
+ // Map each template to a menu item
383
+ const subItems = manage.templates.map((t) => {
384
+ return {
385
+ content: t.name,
386
+ callback: () => {
387
+ clipboardAction(async () => {
388
+ const data = JSON.parse(t.data);
389
+ await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {});
390
+ localStorage.setItem("litegrapheditor_clipboard", t.data);
391
+ app.canvas.pasteFromClipboard();
392
+ });
393
+ },
394
+ };
395
+ });
396
+
397
+ subItems.push(null, {
398
+ content: "Manage",
399
+ callback: () => manage.show(),
400
+ });
401
+
402
+ options.push({
403
+ content: "Node Templates",
404
+ submenu: {
405
+ options: subItems,
406
+ },
407
+ });
408
+
409
+ return options;
410
+ };
411
+ },
412
+ });
ComfyUI/web/extensions/core/noteNode.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {app} from "../../scripts/app.js";
2
+ import {ComfyWidgets} from "../../scripts/widgets.js";
3
+ // Node that add notes to your project
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.NoteNode",
7
+ registerCustomNodes() {
8
+ class NoteNode {
9
+ color=LGraphCanvas.node_colors.yellow.color;
10
+ bgcolor=LGraphCanvas.node_colors.yellow.bgcolor;
11
+ groupcolor = LGraphCanvas.node_colors.yellow.groupcolor;
12
+ constructor() {
13
+ if (!this.properties) {
14
+ this.properties = {};
15
+ this.properties.text="";
16
+ }
17
+
18
+ ComfyWidgets.STRING(this, "", ["", {default:this.properties.text, multiline: true}], app)
19
+
20
+ this.serialize_widgets = true;
21
+ this.isVirtualNode = true;
22
+
23
+ }
24
+
25
+
26
+ }
27
+
28
+ // Load default visibility
29
+
30
+ LiteGraph.registerNodeType(
31
+ "Note",
32
+ Object.assign(NoteNode, {
33
+ title_mode: LiteGraph.NORMAL_TITLE,
34
+ title: "Note",
35
+ collapsable: true,
36
+ })
37
+ );
38
+
39
+ NoteNode.category = "utils";
40
+ },
41
+ });
ComfyUI/web/extensions/core/rerouteNode.js ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { mergeIfValid, getWidgetConfig, setWidgetConfig } from "./widgetInputs.js";
3
+
4
+ // Node that allows you to redirect connections for cleaner graphs
5
+
6
+ app.registerExtension({
7
+ name: "Comfy.RerouteNode",
8
+ registerCustomNodes(app) {
9
+ class RerouteNode {
10
+ constructor() {
11
+ if (!this.properties) {
12
+ this.properties = {};
13
+ }
14
+ this.properties.showOutputText = RerouteNode.defaultVisibility;
15
+ this.properties.horizontal = false;
16
+
17
+ this.addInput("", "*");
18
+ this.addOutput(this.properties.showOutputText ? "*" : "", "*");
19
+
20
+ this.onAfterGraphConfigured = function () {
21
+ requestAnimationFrame(() => {
22
+ this.onConnectionsChange(LiteGraph.INPUT, null, true, null);
23
+ });
24
+ };
25
+
26
+ this.onConnectionsChange = function (type, index, connected, link_info) {
27
+ this.applyOrientation();
28
+
29
+ // Prevent multiple connections to different types when we have no input
30
+ if (connected && type === LiteGraph.OUTPUT) {
31
+ // Ignore wildcard nodes as these will be updated to real types
32
+ const types = new Set(this.outputs[0].links.map((l) => app.graph.links[l].type).filter((t) => t !== "*"));
33
+ if (types.size > 1) {
34
+ const linksToDisconnect = [];
35
+ for (let i = 0; i < this.outputs[0].links.length - 1; i++) {
36
+ const linkId = this.outputs[0].links[i];
37
+ const link = app.graph.links[linkId];
38
+ linksToDisconnect.push(link);
39
+ }
40
+ for (const link of linksToDisconnect) {
41
+ const node = app.graph.getNodeById(link.target_id);
42
+ node.disconnectInput(link.target_slot);
43
+ }
44
+ }
45
+ }
46
+
47
+ // Find root input
48
+ let currentNode = this;
49
+ let updateNodes = [];
50
+ let inputType = null;
51
+ let inputNode = null;
52
+ while (currentNode) {
53
+ updateNodes.unshift(currentNode);
54
+ const linkId = currentNode.inputs[0].link;
55
+ if (linkId !== null) {
56
+ const link = app.graph.links[linkId];
57
+ if (!link) return;
58
+ const node = app.graph.getNodeById(link.origin_id);
59
+ const type = node.constructor.type;
60
+ if (type === "Reroute") {
61
+ if (node === this) {
62
+ // We've found a circle
63
+ currentNode.disconnectInput(link.target_slot);
64
+ currentNode = null;
65
+ } else {
66
+ // Move the previous node
67
+ currentNode = node;
68
+ }
69
+ } else {
70
+ // We've found the end
71
+ inputNode = currentNode;
72
+ inputType = node.outputs[link.origin_slot]?.type ?? null;
73
+ break;
74
+ }
75
+ } else {
76
+ // This path has no input node
77
+ currentNode = null;
78
+ break;
79
+ }
80
+ }
81
+
82
+ // Find all outputs
83
+ const nodes = [this];
84
+ let outputType = null;
85
+ while (nodes.length) {
86
+ currentNode = nodes.pop();
87
+ const outputs = (currentNode.outputs ? currentNode.outputs[0].links : []) || [];
88
+ if (outputs.length) {
89
+ for (const linkId of outputs) {
90
+ const link = app.graph.links[linkId];
91
+
92
+ // When disconnecting sometimes the link is still registered
93
+ if (!link) continue;
94
+
95
+ const node = app.graph.getNodeById(link.target_id);
96
+ const type = node.constructor.type;
97
+
98
+ if (type === "Reroute") {
99
+ // Follow reroute nodes
100
+ nodes.push(node);
101
+ updateNodes.push(node);
102
+ } else {
103
+ // We've found an output
104
+ const nodeOutType =
105
+ node.inputs && node.inputs[link?.target_slot] && node.inputs[link.target_slot].type
106
+ ? node.inputs[link.target_slot].type
107
+ : null;
108
+ if (inputType && inputType !== "*" && nodeOutType !== inputType) {
109
+ // The output doesnt match our input so disconnect it
110
+ node.disconnectInput(link.target_slot);
111
+ } else {
112
+ outputType = nodeOutType;
113
+ }
114
+ }
115
+ }
116
+ } else {
117
+ // No more outputs for this path
118
+ }
119
+ }
120
+
121
+ const displayType = inputType || outputType || "*";
122
+ const color = LGraphCanvas.link_type_colors[displayType];
123
+
124
+ let widgetConfig;
125
+ let targetWidget;
126
+ let widgetType;
127
+ // Update the types of each node
128
+ for (const node of updateNodes) {
129
+ // If we dont have an input type we are always wildcard but we'll show the output type
130
+ // This lets you change the output link to a different type and all nodes will update
131
+ node.outputs[0].type = inputType || "*";
132
+ node.__outputType = displayType;
133
+ node.outputs[0].name = node.properties.showOutputText ? displayType : "";
134
+ node.size = node.computeSize();
135
+ node.applyOrientation();
136
+
137
+ for (const l of node.outputs[0].links || []) {
138
+ const link = app.graph.links[l];
139
+ if (link) {
140
+ link.color = color;
141
+
142
+ if (app.configuringGraph) continue;
143
+ const targetNode = app.graph.getNodeById(link.target_id);
144
+ const targetInput = targetNode.inputs?.[link.target_slot];
145
+ if (targetInput?.widget) {
146
+ const config = getWidgetConfig(targetInput);
147
+ if (!widgetConfig) {
148
+ widgetConfig = config[1] ?? {};
149
+ widgetType = config[0];
150
+ }
151
+ if (!targetWidget) {
152
+ targetWidget = targetNode.widgets?.find((w) => w.name === targetInput.widget.name);
153
+ }
154
+
155
+ const merged = mergeIfValid(targetInput, [config[0], widgetConfig]);
156
+ if (merged.customConfig) {
157
+ widgetConfig = merged.customConfig;
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ for (const node of updateNodes) {
165
+ if (widgetConfig && outputType) {
166
+ node.inputs[0].widget = { name: "value" };
167
+ setWidgetConfig(node.inputs[0], [widgetType ?? displayType, widgetConfig], targetWidget);
168
+ } else {
169
+ setWidgetConfig(node.inputs[0], null);
170
+ }
171
+ }
172
+
173
+ if (inputNode) {
174
+ const link = app.graph.links[inputNode.inputs[0].link];
175
+ if (link) {
176
+ link.color = color;
177
+ }
178
+ }
179
+ };
180
+
181
+ this.clone = function () {
182
+ const cloned = RerouteNode.prototype.clone.apply(this);
183
+ cloned.removeOutput(0);
184
+ cloned.addOutput(this.properties.showOutputText ? "*" : "", "*");
185
+ cloned.size = cloned.computeSize();
186
+ return cloned;
187
+ };
188
+
189
+ // This node is purely frontend and does not impact the resulting prompt so should not be serialized
190
+ this.isVirtualNode = true;
191
+ }
192
+
193
+ getExtraMenuOptions(_, options) {
194
+ options.unshift(
195
+ {
196
+ content: (this.properties.showOutputText ? "Hide" : "Show") + " Type",
197
+ callback: () => {
198
+ this.properties.showOutputText = !this.properties.showOutputText;
199
+ if (this.properties.showOutputText) {
200
+ this.outputs[0].name = this.__outputType || this.outputs[0].type;
201
+ } else {
202
+ this.outputs[0].name = "";
203
+ }
204
+ this.size = this.computeSize();
205
+ this.applyOrientation();
206
+ app.graph.setDirtyCanvas(true, true);
207
+ },
208
+ },
209
+ {
210
+ content: (RerouteNode.defaultVisibility ? "Hide" : "Show") + " Type By Default",
211
+ callback: () => {
212
+ RerouteNode.setDefaultTextVisibility(!RerouteNode.defaultVisibility);
213
+ },
214
+ },
215
+ {
216
+ // naming is inverted with respect to LiteGraphNode.horizontal
217
+ // LiteGraphNode.horizontal == true means that
218
+ // each slot in the inputs and outputs are layed out horizontally,
219
+ // which is the opposite of the visual orientation of the inputs and outputs as a node
220
+ content: "Set " + (this.properties.horizontal ? "Horizontal" : "Vertical"),
221
+ callback: () => {
222
+ this.properties.horizontal = !this.properties.horizontal;
223
+ this.applyOrientation();
224
+ },
225
+ }
226
+ );
227
+ }
228
+ applyOrientation() {
229
+ this.horizontal = this.properties.horizontal;
230
+ if (this.horizontal) {
231
+ // we correct the input position, because LiteGraphNode.horizontal
232
+ // doesn't account for title presence
233
+ // which reroute nodes don't have
234
+ this.inputs[0].pos = [this.size[0] / 2, 0];
235
+ } else {
236
+ delete this.inputs[0].pos;
237
+ }
238
+ app.graph.setDirtyCanvas(true, true);
239
+ }
240
+
241
+ computeSize() {
242
+ return [
243
+ this.properties.showOutputText && this.outputs && this.outputs.length
244
+ ? Math.max(75, LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 + 40)
245
+ : 75,
246
+ 26,
247
+ ];
248
+ }
249
+
250
+ static setDefaultTextVisibility(visible) {
251
+ RerouteNode.defaultVisibility = visible;
252
+ if (visible) {
253
+ localStorage["Comfy.RerouteNode.DefaultVisibility"] = "true";
254
+ } else {
255
+ delete localStorage["Comfy.RerouteNode.DefaultVisibility"];
256
+ }
257
+ }
258
+ }
259
+
260
+ // Load default visibility
261
+ RerouteNode.setDefaultTextVisibility(!!localStorage["Comfy.RerouteNode.DefaultVisibility"]);
262
+
263
+ LiteGraph.registerNodeType(
264
+ "Reroute",
265
+ Object.assign(RerouteNode, {
266
+ title_mode: LiteGraph.NO_TITLE,
267
+ title: "Reroute",
268
+ collapsable: false,
269
+ })
270
+ );
271
+
272
+ RerouteNode.category = "utils";
273
+ },
274
+ });
ComfyUI/web/extensions/core/saveImageExtraOutput.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { applyTextReplacements } from "../../scripts/utils.js";
3
+ // Use widget values and dates in output filenames
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.SaveImageExtraOutput",
7
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
8
+ if (nodeData.name === "SaveImage") {
9
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
10
+ // When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R
11
+ nodeType.prototype.onNodeCreated = function () {
12
+ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
13
+
14
+ const widget = this.widgets.find((w) => w.name === "filename_prefix");
15
+ widget.serializeValue = () => {
16
+ return applyTextReplacements(app, widget.value);
17
+ };
18
+
19
+ return r;
20
+ };
21
+ } else {
22
+ // When any other node is created add a property to alias the node
23
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
24
+ nodeType.prototype.onNodeCreated = function () {
25
+ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
26
+
27
+ if (!this.properties || !("Node name for S&R" in this.properties)) {
28
+ this.addProperty("Node name for S&R", this.constructor.type, "string");
29
+ }
30
+
31
+ return r;
32
+ };
33
+ }
34
+ },
35
+ });
ComfyUI/web/extensions/core/simpleTouchSupport.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ let touchZooming;
4
+ let touchCount = 0;
5
+
6
+ app.registerExtension({
7
+ name: "Comfy.SimpleTouchSupport",
8
+ setup() {
9
+ let zoomPos;
10
+ let touchTime;
11
+ let lastTouch;
12
+
13
+ function getMultiTouchPos(e) {
14
+ return Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
15
+ }
16
+
17
+ app.canvasEl.addEventListener(
18
+ "touchstart",
19
+ (e) => {
20
+ touchCount++;
21
+ lastTouch = null;
22
+ if (e.touches?.length === 1) {
23
+ // Store start time for press+hold for context menu
24
+ touchTime = new Date();
25
+ lastTouch = e.touches[0];
26
+ } else {
27
+ touchTime = null;
28
+ if (e.touches?.length === 2) {
29
+ // Store center pos for zoom
30
+ zoomPos = getMultiTouchPos(e);
31
+ app.canvas.pointer_is_down = false;
32
+ }
33
+ }
34
+ },
35
+ true
36
+ );
37
+
38
+ app.canvasEl.addEventListener("touchend", (e) => {
39
+ touchZooming = false;
40
+ touchCount = e.touches?.length ?? touchCount - 1;
41
+ if (touchTime && !e.touches?.length) {
42
+ if (new Date() - touchTime > 600) {
43
+ try {
44
+ // hack to get litegraph to use this event
45
+ e.constructor = CustomEvent;
46
+ } catch (error) {}
47
+ e.clientX = lastTouch.clientX;
48
+ e.clientY = lastTouch.clientY;
49
+
50
+ app.canvas.pointer_is_down = true;
51
+ app.canvas._mousedown_callback(e);
52
+ }
53
+ touchTime = null;
54
+ }
55
+ });
56
+
57
+ app.canvasEl.addEventListener(
58
+ "touchmove",
59
+ (e) => {
60
+ touchTime = null;
61
+ if (e.touches?.length === 2) {
62
+ app.canvas.pointer_is_down = false;
63
+ touchZooming = true;
64
+ LiteGraph.closeAllContextMenus();
65
+ app.canvas.search_box?.close();
66
+ const newZoomPos = getMultiTouchPos(e);
67
+
68
+ const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
69
+ const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
70
+
71
+ let scale = app.canvas.ds.scale;
72
+ const diff = zoomPos - newZoomPos;
73
+ if (diff > 0.5) {
74
+ scale *= 1 / 1.07;
75
+ } else if (diff < -0.5) {
76
+ scale *= 1.07;
77
+ }
78
+ app.canvas.ds.changeScale(scale, [midX, midY]);
79
+ app.canvas.setDirty(true, true);
80
+ zoomPos = newZoomPos;
81
+ }
82
+ },
83
+ true
84
+ );
85
+ },
86
+ });
87
+
88
+ const processMouseDown = LGraphCanvas.prototype.processMouseDown;
89
+ LGraphCanvas.prototype.processMouseDown = function (e) {
90
+ if (touchZooming || touchCount) {
91
+ return;
92
+ }
93
+ return processMouseDown.apply(this, arguments);
94
+ };
95
+
96
+ const processMouseMove = LGraphCanvas.prototype.processMouseMove;
97
+ LGraphCanvas.prototype.processMouseMove = function (e) {
98
+ if (touchZooming || touchCount > 1) {
99
+ return;
100
+ }
101
+ return processMouseMove.apply(this, arguments);
102
+ };
ComfyUI/web/extensions/core/slotDefaults.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { ComfyWidgets } from "../../scripts/widgets.js";
3
+ // Adds defaults for quickly adding nodes with middle click on the input/output
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.SlotDefaults",
7
+ suggestionsNumber: null,
8
+ init() {
9
+ LiteGraph.search_filter_enabled = true;
10
+ LiteGraph.middle_click_slot_add_default_node = true;
11
+ this.suggestionsNumber = app.ui.settings.addSetting({
12
+ id: "Comfy.NodeSuggestions.number",
13
+ name: "Number of nodes suggestions",
14
+ type: "slider",
15
+ attrs: {
16
+ min: 1,
17
+ max: 100,
18
+ step: 1,
19
+ },
20
+ defaultValue: 5,
21
+ onChange: (newVal, oldVal) => {
22
+ this.setDefaults(newVal);
23
+ }
24
+ });
25
+ },
26
+ slot_types_default_out: {},
27
+ slot_types_default_in: {},
28
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
29
+ var nodeId = nodeData.name;
30
+ var inputs = [];
31
+ inputs = nodeData["input"]["required"]; //only show required inputs to reduce the mess also not logical to create node with optional inputs
32
+ for (const inputKey in inputs) {
33
+ var input = (inputs[inputKey]);
34
+ if (typeof input[0] !== "string") continue;
35
+
36
+ var type = input[0]
37
+ if (type in ComfyWidgets) {
38
+ var customProperties = input[1]
39
+ if (!(customProperties?.forceInput)) continue; //ignore widgets that don't force input
40
+ }
41
+
42
+ if (!(type in this.slot_types_default_out)) {
43
+ this.slot_types_default_out[type] = ["Reroute"];
44
+ }
45
+ if (this.slot_types_default_out[type].includes(nodeId)) continue;
46
+ this.slot_types_default_out[type].push(nodeId);
47
+
48
+ // Input types have to be stored as lower case
49
+ // Store each node that can handle this input type
50
+ const lowerType = type.toLocaleLowerCase();
51
+ if (!(lowerType in LiteGraph.registered_slot_in_types)) {
52
+ LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] };
53
+ }
54
+ LiteGraph.registered_slot_in_types[lowerType].nodes.push(nodeType.comfyClass);
55
+ }
56
+
57
+ var outputs = nodeData["output"];
58
+ for (const key in outputs) {
59
+ var type = outputs[key];
60
+ if (!(type in this.slot_types_default_in)) {
61
+ this.slot_types_default_in[type] = ["Reroute"];// ["Reroute", "Primitive"]; primitive doesn't always work :'()
62
+ }
63
+
64
+ this.slot_types_default_in[type].push(nodeId);
65
+
66
+ // Store each node that can handle this output type
67
+ if (!(type in LiteGraph.registered_slot_out_types)) {
68
+ LiteGraph.registered_slot_out_types[type] = { nodes: [] };
69
+ }
70
+ LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass);
71
+
72
+ if(!LiteGraph.slot_types_out.includes(type)) {
73
+ LiteGraph.slot_types_out.push(type);
74
+ }
75
+ }
76
+ var maxNum = this.suggestionsNumber.value;
77
+ this.setDefaults(maxNum);
78
+ },
79
+ setDefaults(maxNum) {
80
+
81
+ LiteGraph.slot_types_default_out = {};
82
+ LiteGraph.slot_types_default_in = {};
83
+
84
+ for (const type in this.slot_types_default_out) {
85
+ LiteGraph.slot_types_default_out[type] = this.slot_types_default_out[type].slice(0, maxNum);
86
+ }
87
+ for (const type in this.slot_types_default_in) {
88
+ LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[type].slice(0, maxNum);
89
+ }
90
+ }
91
+ });
ComfyUI/web/extensions/core/snapToGrid.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Shift + drag/resize to snap to grid
4
+
5
+ /** Rounds a Vector2 in-place to the current CANVAS_GRID_SIZE. */
6
+ function roundVectorToGrid(vec) {
7
+ vec[0] = LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[0] / LiteGraph.CANVAS_GRID_SIZE);
8
+ vec[1] = LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[1] / LiteGraph.CANVAS_GRID_SIZE);
9
+ return vec;
10
+ }
11
+
12
+ app.registerExtension({
13
+ name: "Comfy.SnapToGrid",
14
+ init() {
15
+ // Add setting to control grid size
16
+ app.ui.settings.addSetting({
17
+ id: "Comfy.SnapToGrid.GridSize",
18
+ name: "Grid Size",
19
+ type: "slider",
20
+ attrs: {
21
+ min: 1,
22
+ max: 500,
23
+ },
24
+ tooltip:
25
+ "When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.",
26
+ defaultValue: LiteGraph.CANVAS_GRID_SIZE,
27
+ onChange(value) {
28
+ LiteGraph.CANVAS_GRID_SIZE = +value;
29
+ },
30
+ });
31
+
32
+ // After moving a node, if the shift key is down align it to grid
33
+ const onNodeMoved = app.canvas.onNodeMoved;
34
+ app.canvas.onNodeMoved = function (node) {
35
+ const r = onNodeMoved?.apply(this, arguments);
36
+
37
+ if (app.shiftDown) {
38
+ // Ensure all selected nodes are realigned
39
+ for (const id in this.selected_nodes) {
40
+ this.selected_nodes[id].alignToGrid();
41
+ }
42
+ }
43
+
44
+ return r;
45
+ };
46
+
47
+ // When a node is added, add a resize handler to it so we can fix align the size with the grid
48
+ const onNodeAdded = app.graph.onNodeAdded;
49
+ app.graph.onNodeAdded = function (node) {
50
+ const onResize = node.onResize;
51
+ node.onResize = function () {
52
+ if (app.shiftDown) {
53
+ roundVectorToGrid(node.size);
54
+ }
55
+ return onResize?.apply(this, arguments);
56
+ };
57
+ return onNodeAdded?.apply(this, arguments);
58
+ };
59
+
60
+ // Draw a preview of where the node will go if holding shift and the node is selected
61
+ const origDrawNode = LGraphCanvas.prototype.drawNode;
62
+ LGraphCanvas.prototype.drawNode = function (node, ctx) {
63
+ if (app.shiftDown && this.node_dragged && node.id in this.selected_nodes) {
64
+ const [x, y] = roundVectorToGrid([...node.pos]);
65
+ const shiftX = x - node.pos[0];
66
+ let shiftY = y - node.pos[1];
67
+
68
+ let w, h;
69
+ if (node.flags.collapsed) {
70
+ w = node._collapsed_width;
71
+ h = LiteGraph.NODE_TITLE_HEIGHT;
72
+ shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
73
+ } else {
74
+ w = node.size[0];
75
+ h = node.size[1];
76
+ let titleMode = node.constructor.title_mode;
77
+ if (titleMode !== LiteGraph.TRANSPARENT_TITLE && titleMode !== LiteGraph.NO_TITLE) {
78
+ h += LiteGraph.NODE_TITLE_HEIGHT;
79
+ shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
80
+ }
81
+ }
82
+ const f = ctx.fillStyle;
83
+ ctx.fillStyle = "rgba(100, 100, 100, 0.5)";
84
+ ctx.fillRect(shiftX, shiftY, w, h);
85
+ ctx.fillStyle = f;
86
+ }
87
+
88
+ return origDrawNode.apply(this, arguments);
89
+ };
90
+
91
+
92
+
93
+ /**
94
+ * The currently moving, selected group only. Set after the `selected_group` has actually started
95
+ * moving.
96
+ */
97
+ let selectedAndMovingGroup = null;
98
+
99
+ /**
100
+ * Handles moving a group; tracking when a group has been moved (to show the ghost in `drawGroups`
101
+ * below) as well as handle the last move call from LiteGraph's `processMouseUp`.
102
+ */
103
+ const groupMove = LGraphGroup.prototype.move;
104
+ LGraphGroup.prototype.move = function(deltax, deltay, ignore_nodes) {
105
+ const v = groupMove.apply(this, arguments);
106
+ // When we've started moving, set `selectedAndMovingGroup` as LiteGraph sets `selected_group`
107
+ // too eagerly and we don't want to behave like we're moving until we get a delta.
108
+ if (!selectedAndMovingGroup && app.canvas.selected_group === this && (deltax || deltay)) {
109
+ selectedAndMovingGroup = this;
110
+ }
111
+
112
+ // LiteGraph will call group.move both on mouse-move as well as mouse-up though we only want
113
+ // to snap on a mouse-up which we can determine by checking if `app.canvas.last_mouse_dragging`
114
+ // has been set to `false`. Essentially, this check here is the equivilant to calling an
115
+ // `LGraphGroup.prototype.onNodeMoved` if it had existed.
116
+ if (app.canvas.last_mouse_dragging === false && app.shiftDown) {
117
+ // After moving a group (while app.shiftDown), snap all the child nodes and, finally,
118
+ // align the group itself.
119
+ this.recomputeInsideNodes();
120
+ for (const node of this._nodes) {
121
+ node.alignToGrid();
122
+ }
123
+ LGraphNode.prototype.alignToGrid.apply(this);
124
+ }
125
+ return v;
126
+ };
127
+
128
+ /**
129
+ * Handles drawing a group when, snapping the size when one is actively being resized tracking and/or
130
+ * drawing a ghost box when one is actively being moved. This mimics the node snapping behavior for
131
+ * both.
132
+ */
133
+ const drawGroups = LGraphCanvas.prototype.drawGroups;
134
+ LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
135
+ if (this.selected_group && app.shiftDown) {
136
+ if (this.selected_group_resizing) {
137
+ roundVectorToGrid(this.selected_group.size);
138
+ } else if (selectedAndMovingGroup) {
139
+ const [x, y] = roundVectorToGrid([...selectedAndMovingGroup.pos]);
140
+ const f = ctx.fillStyle;
141
+ const s = ctx.strokeStyle;
142
+ ctx.fillStyle = "rgba(100, 100, 100, 0.33)";
143
+ ctx.strokeStyle = "rgba(100, 100, 100, 0.66)";
144
+ ctx.rect(x, y, ...selectedAndMovingGroup.size);
145
+ ctx.fill();
146
+ ctx.stroke();
147
+ ctx.fillStyle = f;
148
+ ctx.strokeStyle = s;
149
+ }
150
+ } else if (!this.selected_group) {
151
+ selectedAndMovingGroup = null;
152
+ }
153
+ return drawGroups.apply(this, arguments);
154
+ };
155
+
156
+
157
+ /** Handles adding a group in a snapping-enabled state. */
158
+ const onGroupAdd = LGraphCanvas.onGroupAdd;
159
+ LGraphCanvas.onGroupAdd = function() {
160
+ const v = onGroupAdd.apply(app.canvas, arguments);
161
+ if (app.shiftDown) {
162
+ const lastGroup = app.graph._groups[app.graph._groups.length - 1];
163
+ if (lastGroup) {
164
+ roundVectorToGrid(lastGroup.pos);
165
+ roundVectorToGrid(lastGroup.size);
166
+ }
167
+ }
168
+ return v;
169
+ };
170
+ },
171
+ });
ComfyUI/web/extensions/core/uploadAudio.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js"
2
+ import { api } from "../../scripts/api.js"
3
+
4
+ function splitFilePath(path) {
5
+ const folder_separator = path.lastIndexOf("/")
6
+ if (folder_separator === -1) {
7
+ return ["", path]
8
+ }
9
+ return [
10
+ path.substring(0, folder_separator),
11
+ path.substring(folder_separator + 1)
12
+ ]
13
+ }
14
+
15
+ function getResourceURL(subfolder, filename, type = "input") {
16
+ const params = [
17
+ "filename=" + encodeURIComponent(filename),
18
+ "type=" + type,
19
+ "subfolder=" + subfolder,
20
+ app.getRandParam().substring(1)
21
+ ].join("&")
22
+
23
+ return `/view?${params}`
24
+ }
25
+
26
+ async function uploadFile(
27
+ audioWidget,
28
+ audioUIWidget,
29
+ file,
30
+ updateNode,
31
+ pasted = false
32
+ ) {
33
+ try {
34
+ // Wrap file in formdata so it includes filename
35
+ const body = new FormData()
36
+ body.append("image", file)
37
+ if (pasted) body.append("subfolder", "pasted")
38
+ const resp = await api.fetchApi("/upload/image", {
39
+ method: "POST",
40
+ body
41
+ })
42
+
43
+ if (resp.status === 200) {
44
+ const data = await resp.json()
45
+ // Add the file to the dropdown list and update the widget value
46
+ let path = data.name
47
+ if (data.subfolder) path = data.subfolder + "/" + path
48
+
49
+ if (!audioWidget.options.values.includes(path)) {
50
+ audioWidget.options.values.push(path)
51
+ }
52
+
53
+ if (updateNode) {
54
+ audioUIWidget.element.src = api.apiURL(
55
+ getResourceURL(...splitFilePath(path))
56
+ )
57
+ audioWidget.value = path
58
+ }
59
+ } else {
60
+ alert(resp.status + " - " + resp.statusText)
61
+ }
62
+ } catch (error) {
63
+ alert(error)
64
+ }
65
+ }
66
+
67
+ // AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be
68
+ // present.
69
+ app.registerExtension({
70
+ name: "Comfy.AudioWidget",
71
+ async beforeRegisterNodeDef(nodeType, nodeData) {
72
+ if (["LoadAudio", "SaveAudio", "PreviewAudio"].includes(nodeType.comfyClass)) {
73
+ nodeData.input.required.audioUI = ["AUDIO_UI"]
74
+ }
75
+ },
76
+ getCustomWidgets() {
77
+ return {
78
+ AUDIO_UI(node, inputName) {
79
+ const audio = document.createElement("audio")
80
+ audio.controls = true
81
+ audio.classList.add("comfy-audio")
82
+ audio.setAttribute("name", "media")
83
+
84
+ const audioUIWidget = node.addDOMWidget(
85
+ inputName,
86
+ /* name=*/ "audioUI",
87
+ audio
88
+ )
89
+ // @ts-ignore
90
+ // TODO: Sort out the DOMWidget type.
91
+ audioUIWidget.serialize = false
92
+
93
+ const isOutputNode = node.constructor.nodeData.output_node
94
+ if (isOutputNode) {
95
+ // Hide the audio widget when there is no audio initially.
96
+ audioUIWidget.element.classList.add("empty-audio-widget")
97
+ // Populate the audio widget UI on node execution.
98
+ const onExecuted = node.onExecuted
99
+ node.onExecuted = function(message) {
100
+ onExecuted?.apply(this, arguments)
101
+ const audios = message.audio
102
+ if (!audios) return
103
+ const audio = audios[0]
104
+ audioUIWidget.element.src = api.apiURL(
105
+ getResourceURL(audio.subfolder, audio.filename, audio.type)
106
+ )
107
+ audioUIWidget.element.classList.remove("empty-audio-widget")
108
+ }
109
+ }
110
+ return { widget: audioUIWidget }
111
+ }
112
+ }
113
+ },
114
+ onNodeOutputsUpdated(nodeOutputs) {
115
+ for (const [nodeId, output] of Object.entries(nodeOutputs)) {
116
+ const node = app.graph.getNodeById(Number.parseInt(nodeId));
117
+ if ("audio" in output) {
118
+ const audioUIWidget = node.widgets.find((w) => w.name === "audioUI");
119
+ const audio = output.audio[0];
120
+ audioUIWidget.element.src = api.apiURL(getResourceURL(audio.subfolder, audio.filename, audio.type));
121
+ audioUIWidget.element.classList.remove("empty-audio-widget");
122
+ }
123
+ }
124
+ },
125
+ })
126
+
127
+ app.registerExtension({
128
+ name: "Comfy.UploadAudio",
129
+ async beforeRegisterNodeDef(nodeType, nodeData) {
130
+ if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
131
+ nodeData.input.required.upload = ["AUDIOUPLOAD"]
132
+ }
133
+ },
134
+ getCustomWidgets() {
135
+ return {
136
+ AUDIOUPLOAD(node, inputName) {
137
+ // The widget that allows user to select file.
138
+ const audioWidget = node.widgets.find(w => w.name === "audio")
139
+ const audioUIWidget = node.widgets.find(w => w.name === "audioUI")
140
+
141
+ const onAudioWidgetUpdate = () => {
142
+ audioUIWidget.element.src = api.apiURL(
143
+ getResourceURL(...splitFilePath(audioWidget.value))
144
+ )
145
+ }
146
+ // Initially load default audio file to audioUIWidget.
147
+ if (audioWidget.value) {
148
+ onAudioWidgetUpdate()
149
+ }
150
+ audioWidget.callback = onAudioWidgetUpdate
151
+
152
+ // Load saved audio file widget values if restoring from workflow
153
+ const onGraphConfigured = node.onGraphConfigured;
154
+ node.onGraphConfigured = function() {
155
+ onGraphConfigured?.apply(this, arguments)
156
+ if (audioWidget.value) {
157
+ onAudioWidgetUpdate()
158
+ }
159
+ }
160
+
161
+ const fileInput = document.createElement("input")
162
+ fileInput.type = "file"
163
+ fileInput.accept = "audio/*"
164
+ fileInput.style.display = "none"
165
+ fileInput.onchange = () => {
166
+ if (fileInput.files.length) {
167
+ uploadFile(audioWidget, audioUIWidget, fileInput.files[0], true)
168
+ }
169
+ }
170
+ // The widget to pop up the upload dialog.
171
+ const uploadWidget = node.addWidget(
172
+ "button",
173
+ inputName,
174
+ /* value=*/ "",
175
+ () => {
176
+ fileInput.click()
177
+ }
178
+ )
179
+ uploadWidget.label = "choose file to upload"
180
+ uploadWidget.serialize = false
181
+
182
+ return { widget: uploadWidget }
183
+ }
184
+ }
185
+ }
186
+ })
ComfyUI/web/extensions/core/uploadImage.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Adds an upload button to the nodes
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.UploadImage",
7
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
8
+ if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
9
+ nodeData.input.required.upload = ["IMAGEUPLOAD"];
10
+ }
11
+ },
12
+ });
ComfyUI/web/extensions/core/webcamCapture.js ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { api } from "../../scripts/api.js";
3
+
4
+ const WEBCAM_READY = Symbol();
5
+
6
+ app.registerExtension({
7
+ name: "Comfy.WebcamCapture",
8
+ getCustomWidgets(app) {
9
+ return {
10
+ WEBCAM(node, inputName) {
11
+ let res;
12
+ node[WEBCAM_READY] = new Promise((resolve) => (res = resolve));
13
+
14
+ const container = document.createElement("div");
15
+ container.style.background = "rgba(0,0,0,0.25)";
16
+ container.style.textAlign = "center";
17
+
18
+ const video = document.createElement("video");
19
+ video.style.height = video.style.width = "100%";
20
+
21
+ const loadVideo = async () => {
22
+ try {
23
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
24
+ container.replaceChildren(video);
25
+
26
+ setTimeout(() => res(video), 500); // Fallback as loadedmetadata doesnt fire sometimes?
27
+ video.addEventListener("loadedmetadata", () => res(video), false);
28
+ video.srcObject = stream;
29
+ video.play();
30
+ } catch (error) {
31
+ const label = document.createElement("div");
32
+ label.style.color = "red";
33
+ label.style.overflow = "auto";
34
+ label.style.maxHeight = "100%";
35
+ label.style.whiteSpace = "pre-wrap";
36
+
37
+ if (window.isSecureContext) {
38
+ label.textContent = "Unable to load webcam, please ensure access is granted:\n" + error.message;
39
+ } else {
40
+ label.textContent = "Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n" + error.message;
41
+ }
42
+
43
+ container.replaceChildren(label);
44
+ }
45
+ };
46
+
47
+ loadVideo();
48
+
49
+ return { widget: node.addDOMWidget(inputName, "WEBCAM", container) };
50
+ },
51
+ };
52
+ },
53
+ nodeCreated(node) {
54
+ if ((node.type, node.constructor.comfyClass !== "WebcamCapture")) return;
55
+
56
+ let video;
57
+ const camera = node.widgets.find((w) => w.name === "image");
58
+ const w = node.widgets.find((w) => w.name === "width");
59
+ const h = node.widgets.find((w) => w.name === "height");
60
+ const captureOnQueue = node.widgets.find((w) => w.name === "capture_on_queue");
61
+
62
+ const canvas = document.createElement("canvas");
63
+
64
+ const capture = () => {
65
+ canvas.width = w.value;
66
+ canvas.height = h.value;
67
+ const ctx = canvas.getContext("2d");
68
+ ctx.drawImage(video, 0, 0, w.value, h.value);
69
+ const data = canvas.toDataURL("image/png");
70
+
71
+ const img = new Image();
72
+ img.onload = () => {
73
+ node.imgs = [img];
74
+ app.graph.setDirtyCanvas(true);
75
+ requestAnimationFrame(() => {
76
+ node.setSizeForImage?.();
77
+ });
78
+ };
79
+ img.src = data;
80
+ };
81
+
82
+ const btn = node.addWidget("button", "waiting for camera...", "capture", capture);
83
+ btn.disabled = true;
84
+ btn.serializeValue = () => undefined;
85
+
86
+ camera.serializeValue = async () => {
87
+ if (captureOnQueue.value) {
88
+ capture();
89
+ } else if (!node.imgs?.length) {
90
+ const err = `No webcam image captured`;
91
+ alert(err);
92
+ throw new Error(err);
93
+ }
94
+
95
+ // Upload image to temp storage
96
+ const blob = await new Promise((r) => canvas.toBlob(r));
97
+ const name = `${+new Date()}.png`;
98
+ const file = new File([blob], name);
99
+ const body = new FormData();
100
+ body.append("image", file);
101
+ body.append("subfolder", "webcam");
102
+ body.append("type", "temp");
103
+ const resp = await api.fetchApi("/upload/image", {
104
+ method: "POST",
105
+ body,
106
+ });
107
+ if (resp.status !== 200) {
108
+ const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}`;
109
+ alert(err);
110
+ throw new Error(err);
111
+ }
112
+ return `webcam/${name} [temp]`;
113
+ };
114
+
115
+ node[WEBCAM_READY].then((v) => {
116
+ video = v;
117
+ // If width isnt specified then use video output resolution
118
+ if (!w.value) {
119
+ w.value = video.videoWidth || 640;
120
+ h.value = video.videoHeight || 480;
121
+ }
122
+ btn.disabled = false;
123
+ btn.label = "capture";
124
+ });
125
+ },
126
+ });
ComfyUI/web/extensions/core/widgetInputs.js ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComfyWidgets, addValueControlWidgets } from "../../scripts/widgets.js";
2
+ import { app } from "../../scripts/app.js";
3
+ import { applyTextReplacements } from "../../scripts/utils.js";
4
+
5
+ const CONVERTED_TYPE = "converted-widget";
6
+ const VALID_TYPES = ["STRING", "combo", "number", "BOOLEAN"];
7
+ const CONFIG = Symbol();
8
+ const GET_CONFIG = Symbol();
9
+ const TARGET = Symbol(); // Used for reroutes to specify the real target widget
10
+
11
+ export function getWidgetConfig(slot) {
12
+ return slot.widget[CONFIG] ?? slot.widget[GET_CONFIG]();
13
+ }
14
+
15
+ function getConfig(widgetName) {
16
+ const { nodeData } = this.constructor;
17
+ return nodeData?.input?.required[widgetName] ?? nodeData?.input?.optional?.[widgetName];
18
+ }
19
+
20
+ function isConvertableWidget(widget, config) {
21
+ return (VALID_TYPES.includes(widget.type) || VALID_TYPES.includes(config[0])) && !widget.options?.forceInput;
22
+ }
23
+
24
+ function hideWidget(node, widget, suffix = "") {
25
+ if (widget.type?.startsWith(CONVERTED_TYPE)) return;
26
+ widget.origType = widget.type;
27
+ widget.origComputeSize = widget.computeSize;
28
+ widget.origSerializeValue = widget.serializeValue;
29
+ widget.computeSize = () => [0, -4]; // -4 is due to the gap litegraph adds between widgets automatically
30
+ widget.type = CONVERTED_TYPE + suffix;
31
+ widget.serializeValue = () => {
32
+ // Prevent serializing the widget if we have no input linked
33
+ if (!node.inputs) {
34
+ return undefined;
35
+ }
36
+ let node_input = node.inputs.find((i) => i.widget?.name === widget.name);
37
+
38
+ if (!node_input || !node_input.link) {
39
+ return undefined;
40
+ }
41
+ return widget.origSerializeValue ? widget.origSerializeValue() : widget.value;
42
+ };
43
+
44
+ // Hide any linked widgets, e.g. seed+seedControl
45
+ if (widget.linkedWidgets) {
46
+ for (const w of widget.linkedWidgets) {
47
+ hideWidget(node, w, ":" + widget.name);
48
+ }
49
+ }
50
+ }
51
+
52
+ function showWidget(widget) {
53
+ widget.type = widget.origType;
54
+ widget.computeSize = widget.origComputeSize;
55
+ widget.serializeValue = widget.origSerializeValue;
56
+
57
+ delete widget.origType;
58
+ delete widget.origComputeSize;
59
+ delete widget.origSerializeValue;
60
+
61
+ // Hide any linked widgets, e.g. seed+seedControl
62
+ if (widget.linkedWidgets) {
63
+ for (const w of widget.linkedWidgets) {
64
+ showWidget(w);
65
+ }
66
+ }
67
+ }
68
+
69
+ function convertToInput(node, widget, config) {
70
+ hideWidget(node, widget);
71
+
72
+ const { type } = getWidgetType(config);
73
+
74
+ // Add input and store widget config for creating on primitive node
75
+ const sz = node.size;
76
+ node.addInput(widget.name, type, {
77
+ widget: { name: widget.name, [GET_CONFIG]: () => config },
78
+ });
79
+
80
+ for (const widget of node.widgets) {
81
+ widget.last_y += LiteGraph.NODE_SLOT_HEIGHT;
82
+ }
83
+
84
+ // Restore original size but grow if needed
85
+ node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]);
86
+ }
87
+
88
+ function convertToWidget(node, widget) {
89
+ showWidget(widget);
90
+ const sz = node.size;
91
+ node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name));
92
+
93
+ for (const widget of node.widgets) {
94
+ widget.last_y -= LiteGraph.NODE_SLOT_HEIGHT;
95
+ }
96
+
97
+ // Restore original size but grow if needed
98
+ node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]);
99
+ }
100
+
101
+ function getWidgetType(config) {
102
+ // Special handling for COMBO so we restrict links based on the entries
103
+ let type = config[0];
104
+ if (type instanceof Array) {
105
+ type = "COMBO";
106
+ }
107
+ return { type };
108
+ }
109
+
110
+ function isValidCombo(combo, obj) {
111
+ // New input isnt a combo
112
+ if (!(obj instanceof Array)) {
113
+ console.log(`connection rejected: tried to connect combo to ${obj}`);
114
+ return false;
115
+ }
116
+ // New imput combo has a different size
117
+ if (combo.length !== obj.length) {
118
+ console.log(`connection rejected: combo lists dont match`);
119
+ return false;
120
+ }
121
+ // New input combo has different elements
122
+ if (combo.find((v, i) => obj[i] !== v)) {
123
+ console.log(`connection rejected: combo lists dont match`);
124
+ return false;
125
+ }
126
+
127
+ return true;
128
+ }
129
+
130
+ export function setWidgetConfig(slot, config, target) {
131
+ if (!slot.widget) return;
132
+ if (config) {
133
+ slot.widget[GET_CONFIG] = () => config;
134
+ slot.widget[TARGET] = target;
135
+ } else {
136
+ delete slot.widget;
137
+ }
138
+
139
+ if (slot.link) {
140
+ const link = app.graph.links[slot.link];
141
+ if (link) {
142
+ const originNode = app.graph.getNodeById(link.origin_id);
143
+ if (originNode.type === "PrimitiveNode") {
144
+ if (config) {
145
+ originNode.recreateWidget();
146
+ } else if(!app.configuringGraph) {
147
+ originNode.disconnectOutput(0);
148
+ originNode.onLastDisconnect();
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ export function mergeIfValid(output, config2, forceUpdate, recreateWidget, config1) {
156
+ if (!config1) {
157
+ config1 = output.widget[CONFIG] ?? output.widget[GET_CONFIG]();
158
+ }
159
+
160
+ if (config1[0] instanceof Array) {
161
+ if (!isValidCombo(config1[0], config2[0])) return false;
162
+ } else if (config1[0] !== config2[0]) {
163
+ // Types dont match
164
+ console.log(`connection rejected: types dont match`, config1[0], config2[0]);
165
+ return false;
166
+ }
167
+
168
+ const keys = new Set([...Object.keys(config1[1] ?? {}), ...Object.keys(config2[1] ?? {})]);
169
+
170
+ let customConfig;
171
+ const getCustomConfig = () => {
172
+ if (!customConfig) {
173
+ if (typeof structuredClone === "undefined") {
174
+ customConfig = JSON.parse(JSON.stringify(config1[1] ?? {}));
175
+ } else {
176
+ customConfig = structuredClone(config1[1] ?? {});
177
+ }
178
+ }
179
+ return customConfig;
180
+ };
181
+
182
+ const isNumber = config1[0] === "INT" || config1[0] === "FLOAT";
183
+ for (const k of keys.values()) {
184
+ if (k !== "default" && k !== "forceInput" && k !== "defaultInput" && k !== "control_after_generate" && k !== "multiline") {
185
+ let v1 = config1[1][k];
186
+ let v2 = config2[1]?.[k];
187
+
188
+ if (v1 === v2 || (!v1 && !v2)) continue;
189
+
190
+ if (isNumber) {
191
+ if (k === "min") {
192
+ const theirMax = config2[1]?.["max"];
193
+ if (theirMax != null && v1 > theirMax) {
194
+ console.log("connection rejected: min > max", v1, theirMax);
195
+ return false;
196
+ }
197
+ getCustomConfig()[k] = v1 == null ? v2 : v2 == null ? v1 : Math.max(v1, v2);
198
+ continue;
199
+ } else if (k === "max") {
200
+ const theirMin = config2[1]?.["min"];
201
+ if (theirMin != null && v1 < theirMin) {
202
+ console.log("connection rejected: max < min", v1, theirMin);
203
+ return false;
204
+ }
205
+ getCustomConfig()[k] = v1 == null ? v2 : v2 == null ? v1 : Math.min(v1, v2);
206
+ continue;
207
+ } else if (k === "step") {
208
+ let step;
209
+ if (v1 == null) {
210
+ // No current step
211
+ step = v2;
212
+ } else if (v2 == null) {
213
+ // No new step
214
+ step = v1;
215
+ } else {
216
+ if (v1 < v2) {
217
+ // Ensure v1 is larger for the mod
218
+ const a = v2;
219
+ v2 = v1;
220
+ v1 = a;
221
+ }
222
+ if (v1 % v2) {
223
+ console.log("connection rejected: steps not divisible", "current:", v1, "new:", v2);
224
+ return false;
225
+ }
226
+
227
+ step = v1;
228
+ }
229
+
230
+ getCustomConfig()[k] = step;
231
+ continue;
232
+ }
233
+ }
234
+
235
+ console.log(`connection rejected: config ${k} values dont match`, v1, v2);
236
+ return false;
237
+ }
238
+ }
239
+
240
+ if (customConfig || forceUpdate) {
241
+ if (customConfig) {
242
+ output.widget[CONFIG] = [config1[0], customConfig];
243
+ }
244
+
245
+ const widget = recreateWidget?.call(this);
246
+ // When deleting a node this can be null
247
+ if (widget) {
248
+ const min = widget.options.min;
249
+ const max = widget.options.max;
250
+ if (min != null && widget.value < min) widget.value = min;
251
+ if (max != null && widget.value > max) widget.value = max;
252
+ widget.callback(widget.value);
253
+ }
254
+ }
255
+
256
+ return { customConfig };
257
+ }
258
+
259
+ let useConversionSubmenusSetting;
260
+ app.registerExtension({
261
+ name: "Comfy.WidgetInputs",
262
+ init() {
263
+ useConversionSubmenusSetting = app.ui.settings.addSetting({
264
+ id: "Comfy.NodeInputConversionSubmenus",
265
+ name: "Node widget/input conversion sub-menus",
266
+ tooltip: "In the node context menu, place the entries that convert between input/widget in sub-menus.",
267
+ type: "boolean",
268
+ defaultValue: true,
269
+ });
270
+ },
271
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
272
+ // Add menu options to conver to/from widgets
273
+ const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
274
+ nodeType.prototype.convertWidgetToInput = function (widget) {
275
+ const config = getConfig.call(this, widget.name) ?? [widget.type, widget.options || {}];
276
+ if (!isConvertableWidget(widget, config)) return false;
277
+ convertToInput(this, widget, config);
278
+ return true;
279
+ };
280
+ nodeType.prototype.getExtraMenuOptions = function (_, options) {
281
+ const r = origGetExtraMenuOptions ? origGetExtraMenuOptions.apply(this, arguments) : undefined;
282
+
283
+ if (this.widgets) {
284
+ let toInput = [];
285
+ let toWidget = [];
286
+ for (const w of this.widgets) {
287
+ if (w.options?.forceInput) {
288
+ continue;
289
+ }
290
+ if (w.type === CONVERTED_TYPE) {
291
+ toWidget.push({
292
+ content: `Convert ${w.name} to widget`,
293
+ callback: () => convertToWidget(this, w),
294
+ });
295
+ } else {
296
+ const config = getConfig.call(this, w.name) ?? [w.type, w.options || {}];
297
+ if (isConvertableWidget(w, config)) {
298
+ toInput.push({
299
+ content: `Convert ${w.name} to input`,
300
+ callback: () => convertToInput(this, w, config),
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ //Convert.. main menu
307
+ if (toInput.length) {
308
+ if (useConversionSubmenusSetting.value) {
309
+ options.push({
310
+ content: "Convert Widget to Input",
311
+ submenu: {
312
+ options: toInput,
313
+ },
314
+ });
315
+ } else {
316
+ options.push(...toInput, null);
317
+ }
318
+ }
319
+ if (toWidget.length) {
320
+ if (useConversionSubmenusSetting.value) {
321
+ options.push({
322
+ content: "Convert Input to Widget",
323
+ submenu: {
324
+ options: toWidget,
325
+ },
326
+ });
327
+ } else {
328
+ options.push(...toWidget, null);
329
+ }
330
+ }
331
+ }
332
+
333
+ return r;
334
+ };
335
+
336
+ nodeType.prototype.onGraphConfigured = function () {
337
+ if (!this.inputs) return;
338
+
339
+ for (const input of this.inputs) {
340
+ if (input.widget) {
341
+ if (!input.widget[GET_CONFIG]) {
342
+ input.widget[GET_CONFIG] = () => getConfig.call(this, input.widget.name);
343
+ }
344
+
345
+ // Cleanup old widget config
346
+ if (input.widget.config) {
347
+ if (input.widget.config[0] instanceof Array) {
348
+ // If we are an old converted combo then replace the input type and the stored link data
349
+ input.type = "COMBO";
350
+
351
+ const link = app.graph.links[input.link];
352
+ if (link) {
353
+ link.type = input.type;
354
+ }
355
+ }
356
+ delete input.widget.config;
357
+ }
358
+
359
+ const w = this.widgets.find((w) => w.name === input.widget.name);
360
+ if (w) {
361
+ hideWidget(this, w);
362
+ } else {
363
+ convertToWidget(this, input);
364
+ }
365
+ }
366
+ }
367
+ };
368
+
369
+ const origOnNodeCreated = nodeType.prototype.onNodeCreated;
370
+ nodeType.prototype.onNodeCreated = function () {
371
+ const r = origOnNodeCreated ? origOnNodeCreated.apply(this) : undefined;
372
+
373
+ // When node is created, convert any force/default inputs
374
+ if (!app.configuringGraph && this.widgets) {
375
+ for (const w of this.widgets) {
376
+ if (w?.options?.forceInput || w?.options?.defaultInput) {
377
+ const config = getConfig.call(this, w.name) ?? [w.type, w.options || {}];
378
+ convertToInput(this, w, config);
379
+ }
380
+ }
381
+ }
382
+
383
+ return r;
384
+ };
385
+
386
+ const origOnConfigure = nodeType.prototype.onConfigure;
387
+ nodeType.prototype.onConfigure = function () {
388
+ const r = origOnConfigure ? origOnConfigure.apply(this, arguments) : undefined;
389
+ if (!app.configuringGraph && this.inputs) {
390
+ // On copy + paste of nodes, ensure that widget configs are set up
391
+ for (const input of this.inputs) {
392
+ if (input.widget && !input.widget[GET_CONFIG]) {
393
+ input.widget[GET_CONFIG] = () => getConfig.call(this, input.widget.name);
394
+ const w = this.widgets.find((w) => w.name === input.widget.name);
395
+ if (w) {
396
+ hideWidget(this, w);
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ return r;
403
+ };
404
+
405
+ function isNodeAtPos(pos) {
406
+ for (const n of app.graph._nodes) {
407
+ if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
408
+ return true;
409
+ }
410
+ }
411
+ return false;
412
+ }
413
+
414
+ // Double click a widget input to automatically attach a primitive
415
+ const origOnInputDblClick = nodeType.prototype.onInputDblClick;
416
+ const ignoreDblClick = Symbol();
417
+ nodeType.prototype.onInputDblClick = function (slot) {
418
+ const r = origOnInputDblClick ? origOnInputDblClick.apply(this, arguments) : undefined;
419
+
420
+ const input = this.inputs[slot];
421
+ if (!input.widget || !input[ignoreDblClick]) {
422
+ // Not a widget input or already handled input
423
+ if (!(input.type in ComfyWidgets) && !(input.widget[GET_CONFIG]?.()?.[0] instanceof Array)) {
424
+ return r; //also Not a ComfyWidgets input or combo (do nothing)
425
+ }
426
+ }
427
+
428
+ // Create a primitive node
429
+ const node = LiteGraph.createNode("PrimitiveNode");
430
+ app.graph.add(node);
431
+
432
+ // Calculate a position that wont directly overlap another node
433
+ const pos = [this.pos[0] - node.size[0] - 30, this.pos[1]];
434
+ while (isNodeAtPos(pos)) {
435
+ pos[1] += LiteGraph.NODE_TITLE_HEIGHT;
436
+ }
437
+
438
+ node.pos = pos;
439
+ node.connect(0, this, slot);
440
+ node.title = input.name;
441
+
442
+ // Prevent adding duplicates due to triple clicking
443
+ input[ignoreDblClick] = true;
444
+ setTimeout(() => {
445
+ delete input[ignoreDblClick];
446
+ }, 300);
447
+
448
+ return r;
449
+ };
450
+
451
+ // Prevent connecting COMBO lists to converted inputs that dont match types
452
+ const onConnectInput = nodeType.prototype.onConnectInput;
453
+ nodeType.prototype.onConnectInput = function (targetSlot, type, output, originNode, originSlot) {
454
+ const v = onConnectInput?.(this, arguments);
455
+ // Not a combo, ignore
456
+ if (type !== "COMBO") return v;
457
+ // Primitive output, allow that to handle
458
+ if (originNode.outputs[originSlot].widget) return v;
459
+
460
+ // Ensure target is also a combo
461
+ const targetCombo = this.inputs[targetSlot].widget?.[GET_CONFIG]?.()?.[0];
462
+ if (!targetCombo || !(targetCombo instanceof Array)) return v;
463
+
464
+ // Check they match
465
+ const originConfig = originNode.constructor?.nodeData?.output?.[originSlot];
466
+ if (!originConfig || !isValidCombo(targetCombo, originConfig)) {
467
+ return false;
468
+ }
469
+
470
+ return v;
471
+ };
472
+ },
473
+ registerCustomNodes() {
474
+ const replacePropertyName = "Run widget replace on values";
475
+ class PrimitiveNode {
476
+ constructor() {
477
+ this.addOutput("connect to widget input", "*");
478
+ this.serialize_widgets = true;
479
+ this.isVirtualNode = true;
480
+
481
+ if (!this.properties || !(replacePropertyName in this.properties)) {
482
+ this.addProperty(replacePropertyName, false, "boolean");
483
+ }
484
+ }
485
+
486
+ applyToGraph(extraLinks = []) {
487
+ if (!this.outputs[0].links?.length) return;
488
+
489
+ function get_links(node) {
490
+ let links = [];
491
+ for (const l of node.outputs[0].links) {
492
+ const linkInfo = app.graph.links[l];
493
+ const n = node.graph.getNodeById(linkInfo.target_id);
494
+ if (n.type == "Reroute") {
495
+ links = links.concat(get_links(n));
496
+ } else {
497
+ links.push(l);
498
+ }
499
+ }
500
+ return links;
501
+ }
502
+
503
+ let links = [...get_links(this).map((l) => app.graph.links[l]), ...extraLinks];
504
+ let v = this.widgets?.[0].value;
505
+ if(v && this.properties[replacePropertyName]) {
506
+ v = applyTextReplacements(app, v);
507
+ }
508
+
509
+ // For each output link copy our value over the original widget value
510
+ for (const linkInfo of links) {
511
+ const node = this.graph.getNodeById(linkInfo.target_id);
512
+ const input = node.inputs[linkInfo.target_slot];
513
+ let widget;
514
+ if (input.widget[TARGET]) {
515
+ widget = input.widget[TARGET];
516
+ } else {
517
+ const widgetName = input.widget.name;
518
+ if (widgetName) {
519
+ widget = node.widgets.find((w) => w.name === widgetName);
520
+ }
521
+ }
522
+
523
+ if (widget) {
524
+ widget.value = v;
525
+ if (widget.callback) {
526
+ widget.callback(widget.value, app.canvas, node, app.canvas.graph_mouse, {});
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ refreshComboInNode() {
533
+ const widget = this.widgets?.[0];
534
+ if (widget?.type === "combo") {
535
+ widget.options.values = this.outputs[0].widget[GET_CONFIG]()[0];
536
+
537
+ if (!widget.options.values.includes(widget.value)) {
538
+ widget.value = widget.options.values[0];
539
+ widget.callback(widget.value);
540
+ }
541
+ }
542
+ }
543
+
544
+ onAfterGraphConfigured() {
545
+ if (this.outputs[0].links?.length && !this.widgets?.length) {
546
+ if (!this.#onFirstConnection()) return;
547
+
548
+ // Populate widget values from config data
549
+ if (this.widgets) {
550
+ for (let i = 0; i < this.widgets_values.length; i++) {
551
+ const w = this.widgets[i];
552
+ if (w) {
553
+ w.value = this.widgets_values[i];
554
+ }
555
+ }
556
+ }
557
+
558
+ // Merge values if required
559
+ this.#mergeWidgetConfig();
560
+ }
561
+ }
562
+
563
+ onConnectionsChange(_, index, connected) {
564
+ if (app.configuringGraph) {
565
+ // Dont run while the graph is still setting up
566
+ return;
567
+ }
568
+
569
+ const links = this.outputs[0].links;
570
+ if (connected) {
571
+ if (links?.length && !this.widgets?.length) {
572
+ this.#onFirstConnection();
573
+ }
574
+ } else {
575
+ // We may have removed a link that caused the constraints to change
576
+ this.#mergeWidgetConfig();
577
+
578
+ if (!links?.length) {
579
+ this.onLastDisconnect();
580
+ }
581
+ }
582
+ }
583
+
584
+ onConnectOutput(slot, type, input, target_node, target_slot) {
585
+ // Fires before the link is made allowing us to reject it if it isn't valid
586
+ // No widget, we cant connect
587
+ if (!input.widget) {
588
+ if (!(input.type in ComfyWidgets)) return false;
589
+ }
590
+
591
+ if (this.outputs[slot].links?.length) {
592
+ const valid = this.#isValidConnection(input);
593
+ if (valid) {
594
+ // On connect of additional outputs, copy our value to their widget
595
+ this.applyToGraph([{ target_id: target_node.id, target_slot }]);
596
+ }
597
+ return valid;
598
+ }
599
+ }
600
+
601
+ #onFirstConnection(recreating) {
602
+ // First connection can fire before the graph is ready on initial load so random things can be missing
603
+ if (!this.outputs[0].links) {
604
+ this.onLastDisconnect();
605
+ return;
606
+ }
607
+ const linkId = this.outputs[0].links[0];
608
+ const link = this.graph.links[linkId];
609
+ if (!link) return;
610
+
611
+ const theirNode = this.graph.getNodeById(link.target_id);
612
+ if (!theirNode || !theirNode.inputs) return;
613
+
614
+ const input = theirNode.inputs[link.target_slot];
615
+ if (!input) return;
616
+
617
+ let widget;
618
+ if (!input.widget) {
619
+ if (!(input.type in ComfyWidgets)) return;
620
+ widget = { name: input.name, [GET_CONFIG]: () => [input.type, {}] }; //fake widget
621
+ } else {
622
+ widget = input.widget;
623
+ }
624
+
625
+ const config = widget[GET_CONFIG]?.();
626
+ if (!config) return;
627
+
628
+ const { type } = getWidgetType(config);
629
+ // Update our output to restrict to the widget type
630
+ this.outputs[0].type = type;
631
+ this.outputs[0].name = type;
632
+ this.outputs[0].widget = widget;
633
+
634
+ this.#createWidget(widget[CONFIG] ?? config, theirNode, widget.name, recreating, widget[TARGET]);
635
+ }
636
+
637
+ #createWidget(inputData, node, widgetName, recreating, targetWidget) {
638
+ let type = inputData[0];
639
+
640
+ if (type instanceof Array) {
641
+ type = "COMBO";
642
+ }
643
+
644
+ let widget;
645
+ if (type in ComfyWidgets) {
646
+ widget = (ComfyWidgets[type](this, "value", inputData, app) || {}).widget;
647
+ } else {
648
+ widget = this.addWidget(type, "value", null, () => {}, {});
649
+ }
650
+
651
+ if (targetWidget) {
652
+ widget.value = targetWidget.value;
653
+ } else if (node?.widgets && widget) {
654
+ const theirWidget = node.widgets.find((w) => w.name === widgetName);
655
+ if (theirWidget) {
656
+ widget.value = theirWidget.value;
657
+ }
658
+ }
659
+
660
+ if (!inputData?.[1]?.control_after_generate && (widget.type === "number" || widget.type === "combo")) {
661
+ let control_value = this.widgets_values?.[1];
662
+ if (!control_value) {
663
+ control_value = "fixed";
664
+ }
665
+ addValueControlWidgets(this, widget, control_value, undefined, inputData);
666
+ let filter = this.widgets_values?.[2];
667
+ if (filter && this.widgets.length === 3) {
668
+ this.widgets[2].value = filter;
669
+ }
670
+ }
671
+
672
+ // Restore any saved control values
673
+ const controlValues = this.controlValues;
674
+ if(this.lastType === this.widgets[0].type && controlValues?.length === this.widgets.length - 1) {
675
+ for(let i = 0; i < controlValues.length; i++) {
676
+ this.widgets[i + 1].value = controlValues[i];
677
+ }
678
+ }
679
+
680
+ // When our value changes, update other widgets to reflect our changes
681
+ // e.g. so LoadImage shows correct image
682
+ const callback = widget.callback;
683
+ const self = this;
684
+ widget.callback = function () {
685
+ const r = callback ? callback.apply(this, arguments) : undefined;
686
+ self.applyToGraph();
687
+ return r;
688
+ };
689
+
690
+ if (!recreating) {
691
+ // Grow our node if required
692
+ const sz = this.computeSize();
693
+ if (this.size[0] < sz[0]) {
694
+ this.size[0] = sz[0];
695
+ }
696
+ if (this.size[1] < sz[1]) {
697
+ this.size[1] = sz[1];
698
+ }
699
+
700
+ requestAnimationFrame(() => {
701
+ if (this.onResize) {
702
+ this.onResize(this.size);
703
+ }
704
+ });
705
+ }
706
+ }
707
+
708
+ recreateWidget() {
709
+ const values = this.widgets?.map((w) => w.value);
710
+ this.#removeWidgets();
711
+ this.#onFirstConnection(true);
712
+ if (values?.length) {
713
+ for (let i = 0; i < this.widgets?.length; i++) this.widgets[i].value = values[i];
714
+ }
715
+ return this.widgets?.[0];
716
+ }
717
+
718
+ #mergeWidgetConfig() {
719
+ // Merge widget configs if the node has multiple outputs
720
+ const output = this.outputs[0];
721
+ const links = output.links;
722
+
723
+ const hasConfig = !!output.widget[CONFIG];
724
+ if (hasConfig) {
725
+ delete output.widget[CONFIG];
726
+ }
727
+
728
+ if (links?.length < 2 && hasConfig) {
729
+ // Copy the widget options from the source
730
+ if (links.length) {
731
+ this.recreateWidget();
732
+ }
733
+
734
+ return;
735
+ }
736
+
737
+ const config1 = output.widget[GET_CONFIG]();
738
+ const isNumber = config1[0] === "INT" || config1[0] === "FLOAT";
739
+ if (!isNumber) return;
740
+
741
+ for (const linkId of links) {
742
+ const link = app.graph.links[linkId];
743
+ if (!link) continue; // Can be null when removing a node
744
+
745
+ const theirNode = app.graph.getNodeById(link.target_id);
746
+ const theirInput = theirNode.inputs[link.target_slot];
747
+
748
+ // Call is valid connection so it can merge the configs when validating
749
+ this.#isValidConnection(theirInput, hasConfig);
750
+ }
751
+ }
752
+
753
+ #isValidConnection(input, forceUpdate) {
754
+ // Only allow connections where the configs match
755
+ const output = this.outputs[0];
756
+ const config2 = input.widget[GET_CONFIG]();
757
+ return !!mergeIfValid.call(this, output, config2, forceUpdate, this.recreateWidget);
758
+ }
759
+
760
+ #removeWidgets() {
761
+ if (this.widgets) {
762
+ // Allow widgets to cleanup
763
+ for (const w of this.widgets) {
764
+ if (w.onRemove) {
765
+ w.onRemove();
766
+ }
767
+ }
768
+
769
+ // Temporarily store the current values in case the node is being recreated
770
+ // e.g. by group node conversion
771
+ this.controlValues = [];
772
+ this.lastType = this.widgets[0]?.type;
773
+ for(let i = 1; i < this.widgets.length; i++) {
774
+ this.controlValues.push(this.widgets[i].value);
775
+ }
776
+ setTimeout(() => { delete this.lastType; delete this.controlValues }, 15);
777
+ this.widgets.length = 0;
778
+ }
779
+ }
780
+
781
+ onLastDisconnect() {
782
+ // We cant remove + re-add the output here as if you drag a link over the same link
783
+ // it removes, then re-adds, causing it to break
784
+ this.outputs[0].type = "*";
785
+ this.outputs[0].name = "connect to widget input";
786
+ delete this.outputs[0].widget;
787
+
788
+ this.#removeWidgets();
789
+ }
790
+ }
791
+
792
+ LiteGraph.registerNodeType(
793
+ "PrimitiveNode",
794
+ Object.assign(PrimitiveNode, {
795
+ title: "Primitive",
796
+ })
797
+ );
798
+ PrimitiveNode.category = "utils";
799
+ },
800
+ });
ComfyUI/web/extensions/logging.js.example ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../scripts/app.js";
2
+
3
+ const ext = {
4
+ // Unique name for the extension
5
+ name: "Example.LoggingExtension",
6
+ async init(app) {
7
+ // Any initial setup to run as soon as the page loads
8
+ console.log("[logging]", "extension init");
9
+ },
10
+ async setup(app) {
11
+ // Any setup to run after the app is created
12
+ console.log("[logging]", "extension setup");
13
+ },
14
+ async addCustomNodeDefs(defs, app) {
15
+ // Add custom node definitions
16
+ // These definitions will be configured and registered automatically
17
+ // defs is a lookup core nodes, add yours into this
18
+ console.log("[logging]", "add custom node definitions", "current nodes:", Object.keys(defs));
19
+ },
20
+ async getCustomWidgets(app) {
21
+ // Return custom widget types
22
+ // See ComfyWidgets for widget examples
23
+ console.log("[logging]", "provide custom widgets");
24
+ },
25
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
26
+ // Run custom logic before a node definition is registered with the graph
27
+ console.log("[logging]", "before register node: ", nodeType, nodeData);
28
+
29
+ // This fires for every node definition so only log once
30
+ delete ext.beforeRegisterNodeDef;
31
+ },
32
+ async registerCustomNodes(app) {
33
+ // Register any custom node implementations here allowing for more flexability than a custom node def
34
+ console.log("[logging]", "register custom nodes");
35
+ },
36
+ loadedGraphNode(node, app) {
37
+ // Fires for each node when loading/dragging/etc a workflow json or png
38
+ // If you break something in the backend and want to patch workflows in the frontend
39
+ // This is the place to do this
40
+ console.log("[logging]", "loaded graph node: ", node);
41
+
42
+ // This fires for every node on each load so only log once
43
+ delete ext.loadedGraphNode;
44
+ },
45
+ nodeCreated(node, app) {
46
+ // Fires every time a node is constructed
47
+ // You can modify widgets/add handlers/etc here
48
+ console.log("[logging]", "node created: ", node);
49
+
50
+ // This fires for every node so only log once
51
+ delete ext.nodeCreated;
52
+ }
53
+ };
54
+
55
+ app.registerExtension(ext);
ComfyUI/web/fonts/materialdesignicons-webfont.woff2 ADDED
Binary file (403 kB). View file
 
ComfyUI/web/index.html ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>ComfyUI</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
7
+ <link rel="stylesheet" type="text/css" href="./lib/litegraph.css" />
8
+ <link rel="stylesheet" type="text/css" href="./lib/materialdesignicons.min.css" />
9
+ <link rel="stylesheet" type="text/css" href="./style.css" />
10
+ <link rel="stylesheet" type="text/css" href="./user.css" />
11
+ <script type="text/javascript" src="./lib/litegraph.core.js"></script>
12
+ <script type="text/javascript" src="./lib/litegraph.extensions.js" defer></script>
13
+ <script type="module">
14
+ import { app } from "./scripts/app.js";
15
+ await app.setup();
16
+ window.app = app;
17
+ window.graph = app.graph;
18
+ </script>
19
+ </head>
20
+ <body class="litegraph">
21
+ <div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
22
+ <main class="comfy-user-selection-inner">
23
+ <h1>ComfyUI</h1>
24
+ <form>
25
+ <section>
26
+ <label>New user:
27
+ <input placeholder="Enter a username" />
28
+ </label>
29
+ </section>
30
+ <div class="comfy-user-existing">
31
+ <span class="or-separator">OR</span>
32
+ <section>
33
+ <label>
34
+ Existing user:
35
+ <select>
36
+ <option hidden disabled selected value> Select a user </option>
37
+ </select>
38
+ </label>
39
+ </section>
40
+ </div>
41
+ <footer>
42
+ <span class="comfy-user-error">&nbsp;</span>
43
+ <button class="comfy-btn comfy-user-button-next">Next</button>
44
+ </footer>
45
+ </form>
46
+ </main>
47
+ </div>
48
+ </body>
49
+ </html>
ComfyUI/web/jsconfig.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "/*": ["./*"]
6
+ },
7
+ "lib": ["DOM", "ES2022", "DOM.Iterable"],
8
+ "target": "ES2015",
9
+ "module": "es2020"
10
+ },
11
+ "include": ["."]
12
+ }
ComfyUI/web/lib/litegraph.core.js ADDED
The diff for this file is too large to render. See raw diff
 
ComfyUI/web/lib/litegraph.css ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* this CSS contains only the basic CSS needed to run the app and use it */
2
+
3
+ .lgraphcanvas {
4
+ /*cursor: crosshair;*/
5
+ user-select: none;
6
+ -moz-user-select: none;
7
+ -webkit-user-select: none;
8
+ outline: none;
9
+ font-family: Tahoma, sans-serif;
10
+ }
11
+
12
+ .lgraphcanvas * {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ .litegraph.litecontextmenu {
17
+ font-family: Tahoma, sans-serif;
18
+ position: fixed;
19
+ top: 100px;
20
+ left: 100px;
21
+ min-width: 100px;
22
+ color: #aaf;
23
+ padding: 0;
24
+ box-shadow: 0 0 10px black !important;
25
+ background-color: #2e2e2e !important;
26
+ z-index: 10;
27
+ }
28
+
29
+ .litegraph.litecontextmenu.dark {
30
+ background-color: #000 !important;
31
+ }
32
+
33
+ .litegraph.litecontextmenu .litemenu-title img {
34
+ margin-top: 2px;
35
+ margin-left: 2px;
36
+ margin-right: 4px;
37
+ }
38
+
39
+ .litegraph.litecontextmenu .litemenu-entry {
40
+ margin: 2px;
41
+ padding: 2px;
42
+ }
43
+
44
+ .litegraph.litecontextmenu .litemenu-entry.submenu {
45
+ background-color: #2e2e2e !important;
46
+ }
47
+
48
+ .litegraph.litecontextmenu.dark .litemenu-entry.submenu {
49
+ background-color: #000 !important;
50
+ }
51
+
52
+ .litegraph .litemenubar ul {
53
+ font-family: Tahoma, sans-serif;
54
+ margin: 0;
55
+ padding: 0;
56
+ }
57
+
58
+ .litegraph .litemenubar li {
59
+ font-size: 14px;
60
+ color: #999;
61
+ display: inline-block;
62
+ min-width: 50px;
63
+ padding-left: 10px;
64
+ padding-right: 10px;
65
+ user-select: none;
66
+ -moz-user-select: none;
67
+ -webkit-user-select: none;
68
+ cursor: pointer;
69
+ }
70
+
71
+ .litegraph .litemenubar li:hover {
72
+ background-color: #777;
73
+ color: #eee;
74
+ }
75
+
76
+ .litegraph .litegraph .litemenubar-panel {
77
+ position: absolute;
78
+ top: 5px;
79
+ left: 5px;
80
+ min-width: 100px;
81
+ background-color: #444;
82
+ box-shadow: 0 0 3px black;
83
+ padding: 4px;
84
+ border-bottom: 2px solid #aaf;
85
+ z-index: 10;
86
+ }
87
+
88
+ .litegraph .litemenu-entry,
89
+ .litemenu-title {
90
+ font-size: 12px;
91
+ color: #aaa;
92
+ padding: 0 0 0 4px;
93
+ margin: 2px;
94
+ padding-left: 2px;
95
+ -moz-user-select: none;
96
+ -webkit-user-select: none;
97
+ user-select: none;
98
+ cursor: pointer;
99
+ }
100
+
101
+ .litegraph .litemenu-entry .icon {
102
+ display: inline-block;
103
+ width: 12px;
104
+ height: 12px;
105
+ margin: 2px;
106
+ vertical-align: top;
107
+ }
108
+
109
+ .litegraph .litemenu-entry.checked .icon {
110
+ background-color: #aaf;
111
+ }
112
+
113
+ .litegraph .litemenu-entry .more {
114
+ float: right;
115
+ padding-right: 5px;
116
+ }
117
+
118
+ .litegraph .litemenu-entry.disabled {
119
+ opacity: 0.5;
120
+ cursor: default;
121
+ }
122
+
123
+ .litegraph .litemenu-entry.separator {
124
+ display: block;
125
+ border-top: 1px solid #333;
126
+ border-bottom: 1px solid #666;
127
+ width: 100%;
128
+ height: 0px;
129
+ margin: 3px 0 2px 0;
130
+ background-color: transparent;
131
+ padding: 0 !important;
132
+ cursor: default !important;
133
+ }
134
+
135
+ .litegraph .litemenu-entry.has_submenu {
136
+ border-right: 2px solid cyan;
137
+ }
138
+
139
+ .litegraph .litemenu-title {
140
+ color: #dde;
141
+ background-color: #111;
142
+ margin: 0;
143
+ padding: 2px;
144
+ cursor: default;
145
+ }
146
+
147
+ .litegraph .litemenu-entry:hover:not(.disabled):not(.separator) {
148
+ background-color: #444 !important;
149
+ color: #eee;
150
+ transition: all 0.2s;
151
+ }
152
+
153
+ .litegraph .litemenu-entry .property_name {
154
+ display: inline-block;
155
+ text-align: left;
156
+ min-width: 80px;
157
+ min-height: 1.2em;
158
+ }
159
+
160
+ .litegraph .litemenu-entry .property_value {
161
+ display: inline-block;
162
+ background-color: rgba(0, 0, 0, 0.5);
163
+ text-align: right;
164
+ min-width: 80px;
165
+ min-height: 1.2em;
166
+ vertical-align: middle;
167
+ padding-right: 10px;
168
+ }
169
+
170
+ .litegraph.litesearchbox {
171
+ font-family: Tahoma, sans-serif;
172
+ position: absolute;
173
+ background-color: rgba(0, 0, 0, 0.5);
174
+ padding-top: 4px;
175
+ }
176
+
177
+ .litegraph.litesearchbox input,
178
+ .litegraph.litesearchbox select {
179
+ margin-top: 3px;
180
+ min-width: 60px;
181
+ min-height: 1.5em;
182
+ background-color: black;
183
+ border: 0;
184
+ color: white;
185
+ padding-left: 10px;
186
+ margin-right: 5px;
187
+ max-width: 300px;
188
+ }
189
+
190
+ .litegraph.litesearchbox .name {
191
+ display: inline-block;
192
+ min-width: 60px;
193
+ min-height: 1.5em;
194
+ padding-left: 10px;
195
+ }
196
+
197
+ .litegraph.litesearchbox .helper {
198
+ overflow: auto;
199
+ max-height: 200px;
200
+ margin-top: 2px;
201
+ }
202
+
203
+ .litegraph.lite-search-item {
204
+ font-family: Tahoma, sans-serif;
205
+ background-color: rgba(0, 0, 0, 0.5);
206
+ color: white;
207
+ padding-top: 2px;
208
+ }
209
+
210
+ .litegraph.lite-search-item.not_in_filter{
211
+ /*background-color: rgba(50, 50, 50, 0.5);*/
212
+ /*color: #999;*/
213
+ color: #B99;
214
+ font-style: italic;
215
+ }
216
+
217
+ .litegraph.lite-search-item.generic_type{
218
+ /*background-color: rgba(50, 50, 50, 0.5);*/
219
+ /*color: #DD9;*/
220
+ color: #999;
221
+ font-style: italic;
222
+ }
223
+
224
+ .litegraph.lite-search-item:hover,
225
+ .litegraph.lite-search-item.selected {
226
+ cursor: pointer;
227
+ background-color: white;
228
+ color: black;
229
+ }
230
+
231
+ .litegraph.lite-search-item-type {
232
+ display: inline-block;
233
+ background: rgba(0,0,0,0.2);
234
+ margin-left: 5px;
235
+ font-size: 14px;
236
+ padding: 2px 5px;
237
+ position: relative;
238
+ top: -2px;
239
+ opacity: 0.8;
240
+ border-radius: 4px;
241
+ }
242
+
243
+ /* DIALOGs ******/
244
+
245
+ .litegraph .dialog {
246
+ position: absolute;
247
+ top: 50%;
248
+ left: 50%;
249
+ margin-top: -150px;
250
+ margin-left: -200px;
251
+
252
+ background-color: #2A2A2A;
253
+
254
+ min-width: 400px;
255
+ min-height: 200px;
256
+ box-shadow: 0 0 4px #111;
257
+ border-radius: 6px;
258
+ }
259
+
260
+ .litegraph .dialog.settings {
261
+ left: 10px;
262
+ top: 10px;
263
+ height: calc( 100% - 20px );
264
+ margin: auto;
265
+ max-width: 50%;
266
+ }
267
+
268
+ .litegraph .dialog.centered {
269
+ top: 50px;
270
+ left: 50%;
271
+ position: absolute;
272
+ transform: translateX(-50%);
273
+ min-width: 600px;
274
+ min-height: 300px;
275
+ height: calc( 100% - 100px );
276
+ margin: auto;
277
+ }
278
+
279
+ .litegraph .dialog .close {
280
+ float: right;
281
+ margin: 4px;
282
+ margin-right: 10px;
283
+ cursor: pointer;
284
+ font-size: 1.4em;
285
+ }
286
+
287
+ .litegraph .dialog .close:hover {
288
+ color: white;
289
+ }
290
+
291
+ .litegraph .dialog .dialog-header {
292
+ color: #AAA;
293
+ border-bottom: 1px solid #161616;
294
+ }
295
+
296
+ .litegraph .dialog .dialog-header { height: 40px; }
297
+ .litegraph .dialog .dialog-footer { height: 50px; padding: 10px; border-top: 1px solid #1a1a1a;}
298
+
299
+ .litegraph .dialog .dialog-header .dialog-title {
300
+ font: 20px "Arial";
301
+ margin: 4px;
302
+ padding: 4px 10px;
303
+ display: inline-block;
304
+ }
305
+
306
+ .litegraph .dialog .dialog-content, .litegraph .dialog .dialog-alt-content {
307
+ height: calc(100% - 90px);
308
+ width: 100%;
309
+ min-height: 100px;
310
+ display: inline-block;
311
+ color: #AAA;
312
+ /*background-color: black;*/
313
+ overflow: auto;
314
+ }
315
+
316
+ .litegraph .dialog .dialog-content h3 {
317
+ margin: 10px;
318
+ }
319
+
320
+ .litegraph .dialog .dialog-content .connections {
321
+ flex-direction: row;
322
+ }
323
+
324
+ .litegraph .dialog .dialog-content .connections .connections_side {
325
+ width: calc(50% - 5px);
326
+ min-height: 100px;
327
+ background-color: black;
328
+ display: flex;
329
+ }
330
+
331
+ .litegraph .dialog .node_type {
332
+ font-size: 1.2em;
333
+ display: block;
334
+ margin: 10px;
335
+ }
336
+
337
+ .litegraph .dialog .node_desc {
338
+ opacity: 0.5;
339
+ display: block;
340
+ margin: 10px;
341
+ }
342
+
343
+ .litegraph .dialog .separator {
344
+ display: block;
345
+ width: calc( 100% - 4px );
346
+ height: 1px;
347
+ border-top: 1px solid #000;
348
+ border-bottom: 1px solid #333;
349
+ margin: 10px 2px;
350
+ padding: 0;
351
+ }
352
+
353
+ .litegraph .dialog .property {
354
+ margin-bottom: 2px;
355
+ padding: 4px;
356
+ }
357
+
358
+ .litegraph .dialog .property:hover {
359
+ background: #545454;
360
+ }
361
+
362
+ .litegraph .dialog .property_name {
363
+ color: #737373;
364
+ display: inline-block;
365
+ text-align: left;
366
+ vertical-align: top;
367
+ width: 160px;
368
+ padding-left: 4px;
369
+ overflow: hidden;
370
+ margin-right: 6px;
371
+ }
372
+
373
+ .litegraph .dialog .property:hover .property_name {
374
+ color: white;
375
+ }
376
+
377
+ .litegraph .dialog .property_value {
378
+ display: inline-block;
379
+ text-align: right;
380
+ color: #AAA;
381
+ background-color: #1A1A1A;
382
+ /*width: calc( 100% - 122px );*/
383
+ max-width: calc( 100% - 162px );
384
+ min-width: 200px;
385
+ max-height: 300px;
386
+ min-height: 20px;
387
+ padding: 4px;
388
+ padding-right: 12px;
389
+ overflow: hidden;
390
+ cursor: pointer;
391
+ border-radius: 3px;
392
+ }
393
+
394
+ .litegraph .dialog .property_value:hover {
395
+ color: white;
396
+ }
397
+
398
+ .litegraph .dialog .property.boolean .property_value {
399
+ padding-right: 30px;
400
+ color: #A88;
401
+ /*width: auto;
402
+ float: right;*/
403
+ }
404
+
405
+ .litegraph .dialog .property.boolean.bool-on .property_name{
406
+ color: #8A8;
407
+ }
408
+ .litegraph .dialog .property.boolean.bool-on .property_value{
409
+ color: #8A8;
410
+ }
411
+
412
+ .litegraph .dialog .btn {
413
+ border: 0;
414
+ border-radius: 4px;
415
+ padding: 4px 20px;
416
+ margin-left: 0px;
417
+ background-color: #060606;
418
+ color: #8e8e8e;
419
+ }
420
+
421
+ .litegraph .dialog .btn:hover {
422
+ background-color: #111;
423
+ color: #FFF;
424
+ }
425
+
426
+ .litegraph .dialog .btn.delete:hover {
427
+ background-color: #F33;
428
+ color: black;
429
+ }
430
+
431
+ .litegraph .subgraph_property {
432
+ padding: 4px;
433
+ }
434
+
435
+ .litegraph .subgraph_property:hover {
436
+ background-color: #333;
437
+ }
438
+
439
+ .litegraph .subgraph_property.extra {
440
+ margin-top: 8px;
441
+ }
442
+
443
+ .litegraph .subgraph_property span.name {
444
+ font-size: 1.3em;
445
+ padding-left: 4px;
446
+ }
447
+
448
+ .litegraph .subgraph_property span.type {
449
+ opacity: 0.5;
450
+ margin-right: 20px;
451
+ padding-left: 4px;
452
+ }
453
+
454
+ .litegraph .subgraph_property span.label {
455
+ display: inline-block;
456
+ width: 60px;
457
+ padding: 0px 10px;
458
+ }
459
+
460
+ .litegraph .subgraph_property input {
461
+ width: 140px;
462
+ color: #999;
463
+ background-color: #1A1A1A;
464
+ border-radius: 4px;
465
+ border: 0;
466
+ margin-right: 10px;
467
+ padding: 4px;
468
+ padding-left: 10px;
469
+ }
470
+
471
+ .litegraph .subgraph_property button {
472
+ background-color: #1c1c1c;
473
+ color: #aaa;
474
+ border: 0;
475
+ border-radius: 2px;
476
+ padding: 4px 10px;
477
+ cursor: pointer;
478
+ }
479
+
480
+ .litegraph .subgraph_property.extra {
481
+ color: #ccc;
482
+ }
483
+
484
+ .litegraph .subgraph_property.extra input {
485
+ background-color: #111;
486
+ }
487
+
488
+ .litegraph .bullet_icon {
489
+ margin-left: 10px;
490
+ border-radius: 10px;
491
+ width: 12px;
492
+ height: 12px;
493
+ background-color: #666;
494
+ display: inline-block;
495
+ margin-top: 2px;
496
+ margin-right: 4px;
497
+ transition: background-color 0.1s ease 0s;
498
+ -moz-transition: background-color 0.1s ease 0s;
499
+ }
500
+
501
+ .litegraph .bullet_icon:hover {
502
+ background-color: #698;
503
+ cursor: pointer;
504
+ }
505
+
506
+ /* OLD */
507
+
508
+ .graphcontextmenu {
509
+ padding: 4px;
510
+ min-width: 100px;
511
+ }
512
+
513
+ .graphcontextmenu-title {
514
+ color: #dde;
515
+ background-color: #222;
516
+ margin: 0;
517
+ padding: 2px;
518
+ cursor: default;
519
+ }
520
+
521
+ .graphmenu-entry {
522
+ box-sizing: border-box;
523
+ margin: 2px;
524
+ padding-left: 20px;
525
+ user-select: none;
526
+ -moz-user-select: none;
527
+ -webkit-user-select: none;
528
+ transition: all linear 0.3s;
529
+ }
530
+
531
+ .graphmenu-entry.event,
532
+ .litemenu-entry.event {
533
+ border-left: 8px solid orange;
534
+ padding-left: 12px;
535
+ }
536
+
537
+ .graphmenu-entry.disabled {
538
+ opacity: 0.3;
539
+ }
540
+
541
+ .graphmenu-entry.submenu {
542
+ border-right: 2px solid #eee;
543
+ }
544
+
545
+ .graphmenu-entry:hover {
546
+ background-color: #555;
547
+ }
548
+
549
+ .graphmenu-entry.separator {
550
+ background-color: #111;
551
+ border-bottom: 1px solid #666;
552
+ height: 1px;
553
+ width: calc(100% - 20px);
554
+ -moz-width: calc(100% - 20px);
555
+ -webkit-width: calc(100% - 20px);
556
+ }
557
+
558
+ .graphmenu-entry .property_name {
559
+ display: inline-block;
560
+ text-align: left;
561
+ min-width: 80px;
562
+ min-height: 1.2em;
563
+ }
564
+
565
+ .graphmenu-entry .property_value,
566
+ .litemenu-entry .property_value {
567
+ display: inline-block;
568
+ background-color: rgba(0, 0, 0, 0.5);
569
+ text-align: right;
570
+ min-width: 80px;
571
+ min-height: 1.2em;
572
+ vertical-align: middle;
573
+ padding-right: 10px;
574
+ }
575
+
576
+ .graphdialog {
577
+ position: absolute;
578
+ top: 10px;
579
+ left: 10px;
580
+ min-height: 2em;
581
+ background-color: #333;
582
+ font-size: 1.2em;
583
+ box-shadow: 0 0 10px black !important;
584
+ z-index: 10;
585
+ }
586
+
587
+ .graphdialog.rounded {
588
+ border-radius: 12px;
589
+ padding-right: 2px;
590
+ }
591
+
592
+ .graphdialog .name {
593
+ display: inline-block;
594
+ min-width: 60px;
595
+ min-height: 1.5em;
596
+ padding-left: 10px;
597
+ }
598
+
599
+ .graphdialog input,
600
+ .graphdialog textarea,
601
+ .graphdialog select {
602
+ margin: 3px;
603
+ min-width: 60px;
604
+ min-height: 1.5em;
605
+ background-color: black;
606
+ border: 0;
607
+ color: white;
608
+ padding-left: 10px;
609
+ outline: none;
610
+ }
611
+
612
+ .graphdialog textarea {
613
+ min-height: 150px;
614
+ }
615
+
616
+ .graphdialog button {
617
+ margin-top: 3px;
618
+ vertical-align: top;
619
+ background-color: #999;
620
+ border: 0;
621
+ }
622
+
623
+ .graphdialog button.rounded,
624
+ .graphdialog input.rounded {
625
+ border-radius: 0 12px 12px 0;
626
+ }
627
+
628
+ .graphdialog .helper {
629
+ overflow: auto;
630
+ max-height: 200px;
631
+ }
632
+
633
+ .graphdialog .help-item {
634
+ padding-left: 10px;
635
+ }
636
+
637
+ .graphdialog .help-item:hover,
638
+ .graphdialog .help-item.selected {
639
+ cursor: pointer;
640
+ background-color: white;
641
+ color: black;
642
+ }
643
+
644
+ .litegraph .dialog {
645
+ min-height: 0;
646
+ }
647
+ .litegraph .dialog .dialog-content {
648
+ display: block;
649
+ }
650
+ .litegraph .dialog .dialog-content .subgraph_property {
651
+ padding: 5px;
652
+ }
653
+ .litegraph .dialog .dialog-footer {
654
+ margin: 0;
655
+ }
656
+ .litegraph .dialog .dialog-footer .subgraph_property {
657
+ margin-top: 0;
658
+ display: flex;
659
+ align-items: center;
660
+ padding: 5px;
661
+ }
662
+ .litegraph .dialog .dialog-footer .subgraph_property .name {
663
+ flex: 1;
664
+ }
665
+ .litegraph .graphdialog {
666
+ display: flex;
667
+ align-items: center;
668
+ border-radius: 20px;
669
+ padding: 4px 10px;
670
+ position: fixed;
671
+ }
672
+ .litegraph .graphdialog .name {
673
+ padding: 0;
674
+ min-height: 0;
675
+ font-size: 16px;
676
+ vertical-align: middle;
677
+ }
678
+ .litegraph .graphdialog .value {
679
+ font-size: 16px;
680
+ min-height: 0;
681
+ margin: 0 10px;
682
+ padding: 2px 5px;
683
+ }
684
+ .litegraph .graphdialog input[type="checkbox"] {
685
+ width: 16px;
686
+ height: 16px;
687
+ }
688
+ .litegraph .graphdialog button {
689
+ padding: 4px 18px;
690
+ border-radius: 20px;
691
+ cursor: pointer;
692
+ }
693
+
ComfyUI/web/lib/litegraph.extensions.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Changes the background color of the canvas.
3
+ *
4
+ * @method updateBackground
5
+ * @param {image} String
6
+ * @param {clearBackgroundColor} String
7
+ * @
8
+ */
9
+ LGraphCanvas.prototype.updateBackground = function (image, clearBackgroundColor) {
10
+ this._bg_img = new Image();
11
+ this._bg_img.name = image;
12
+ this._bg_img.src = image;
13
+ this._bg_img.onload = () => {
14
+ this.draw(true, true);
15
+ };
16
+ this.background_image = image;
17
+
18
+ this.clear_background = true;
19
+ this.clear_background_color = clearBackgroundColor;
20
+ this._pattern = null
21
+ }
ComfyUI/web/lib/materialdesignicons.min.css ADDED
The diff for this file is too large to render. See raw diff
 
ComfyUI/web/scripts/api.js ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class ComfyApi extends EventTarget {
2
+ #registered = new Set();
3
+
4
+ constructor() {
5
+ super();
6
+ this.api_host = location.host;
7
+ this.api_base = location.pathname.split('/').slice(0, -1).join('/');
8
+ this.initialClientId = sessionStorage.getItem("clientId");
9
+ }
10
+
11
+ apiURL(route) {
12
+ return this.api_base + route;
13
+ }
14
+
15
+ fetchApi(route, options) {
16
+ if (!options) {
17
+ options = {};
18
+ }
19
+ if (!options.headers) {
20
+ options.headers = {};
21
+ }
22
+ options.headers["Comfy-User"] = this.user;
23
+ return fetch(this.apiURL(route), options);
24
+ }
25
+
26
+ addEventListener(type, callback, options) {
27
+ super.addEventListener(type, callback, options);
28
+ this.#registered.add(type);
29
+ }
30
+
31
+ /**
32
+ * Poll status for colab and other things that don't support websockets.
33
+ */
34
+ #pollQueue() {
35
+ setInterval(async () => {
36
+ try {
37
+ const resp = await this.fetchApi("/prompt");
38
+ const status = await resp.json();
39
+ this.dispatchEvent(new CustomEvent("status", { detail: status }));
40
+ } catch (error) {
41
+ this.dispatchEvent(new CustomEvent("status", { detail: null }));
42
+ }
43
+ }, 1000);
44
+ }
45
+
46
+ /**
47
+ * Creates and connects a WebSocket for realtime updates
48
+ * @param {boolean} isReconnect If the socket is connection is a reconnect attempt
49
+ */
50
+ #createSocket(isReconnect) {
51
+ if (this.socket) {
52
+ return;
53
+ }
54
+
55
+ let opened = false;
56
+ let existingSession = window.name;
57
+ if (existingSession) {
58
+ existingSession = "?clientId=" + existingSession;
59
+ }
60
+ this.socket = new WebSocket(
61
+ `ws${window.location.protocol === "https:" ? "s" : ""}://${this.api_host}${this.api_base}/ws${existingSession}`
62
+ );
63
+ this.socket.binaryType = "arraybuffer";
64
+
65
+ this.socket.addEventListener("open", () => {
66
+ opened = true;
67
+ if (isReconnect) {
68
+ this.dispatchEvent(new CustomEvent("reconnected"));
69
+ }
70
+ });
71
+
72
+ this.socket.addEventListener("error", () => {
73
+ if (this.socket) this.socket.close();
74
+ if (!isReconnect && !opened) {
75
+ this.#pollQueue();
76
+ }
77
+ });
78
+
79
+ this.socket.addEventListener("close", () => {
80
+ setTimeout(() => {
81
+ this.socket = null;
82
+ this.#createSocket(true);
83
+ }, 300);
84
+ if (opened) {
85
+ this.dispatchEvent(new CustomEvent("status", { detail: null }));
86
+ this.dispatchEvent(new CustomEvent("reconnecting"));
87
+ }
88
+ });
89
+
90
+ this.socket.addEventListener("message", (event) => {
91
+ try {
92
+ if (event.data instanceof ArrayBuffer) {
93
+ const view = new DataView(event.data);
94
+ const eventType = view.getUint32(0);
95
+ const buffer = event.data.slice(4);
96
+ switch (eventType) {
97
+ case 1:
98
+ const view2 = new DataView(event.data);
99
+ const imageType = view2.getUint32(0)
100
+ let imageMime
101
+ switch (imageType) {
102
+ case 1:
103
+ default:
104
+ imageMime = "image/jpeg";
105
+ break;
106
+ case 2:
107
+ imageMime = "image/png"
108
+ }
109
+ const imageBlob = new Blob([buffer.slice(4)], { type: imageMime });
110
+ this.dispatchEvent(new CustomEvent("b_preview", { detail: imageBlob }));
111
+ break;
112
+ default:
113
+ throw new Error(`Unknown binary websocket message of type ${eventType}`);
114
+ }
115
+ }
116
+ else {
117
+ const msg = JSON.parse(event.data);
118
+ switch (msg.type) {
119
+ case "status":
120
+ if (msg.data.sid) {
121
+ this.clientId = msg.data.sid;
122
+ window.name = this.clientId; // use window name so it isnt reused when duplicating tabs
123
+ sessionStorage.setItem("clientId", this.clientId); // store in session storage so duplicate tab can load correct workflow
124
+ }
125
+ this.dispatchEvent(new CustomEvent("status", { detail: msg.data.status }));
126
+ break;
127
+ case "progress":
128
+ this.dispatchEvent(new CustomEvent("progress", { detail: msg.data }));
129
+ break;
130
+ case "executing":
131
+ this.dispatchEvent(new CustomEvent("executing", { detail: msg.data.node }));
132
+ break;
133
+ case "executed":
134
+ this.dispatchEvent(new CustomEvent("executed", { detail: msg.data }));
135
+ break;
136
+ case "execution_start":
137
+ this.dispatchEvent(new CustomEvent("execution_start", { detail: msg.data }));
138
+ break;
139
+ case "execution_success":
140
+ this.dispatchEvent(new CustomEvent("execution_success", { detail: msg.data }));
141
+ break;
142
+ case "execution_error":
143
+ this.dispatchEvent(new CustomEvent("execution_error", { detail: msg.data }));
144
+ break;
145
+ case "execution_cached":
146
+ this.dispatchEvent(new CustomEvent("execution_cached", { detail: msg.data }));
147
+ break;
148
+ default:
149
+ if (this.#registered.has(msg.type)) {
150
+ this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }));
151
+ } else {
152
+ throw new Error(`Unknown message type ${msg.type}`);
153
+ }
154
+ }
155
+ }
156
+ } catch (error) {
157
+ console.warn("Unhandled message:", event.data, error);
158
+ }
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Initialises sockets and realtime updates
164
+ */
165
+ init() {
166
+ this.#createSocket();
167
+ }
168
+
169
+ /**
170
+ * Gets a list of extension urls
171
+ * @returns An array of script urls to import
172
+ */
173
+ async getExtensions() {
174
+ const resp = await this.fetchApi("/extensions", { cache: "no-store" });
175
+ return await resp.json();
176
+ }
177
+
178
+ /**
179
+ * Gets a list of embedding names
180
+ * @returns An array of script urls to import
181
+ */
182
+ async getEmbeddings() {
183
+ const resp = await this.fetchApi("/embeddings", { cache: "no-store" });
184
+ return await resp.json();
185
+ }
186
+
187
+ /**
188
+ * Loads node object definitions for the graph
189
+ * @returns The node definitions
190
+ */
191
+ async getNodeDefs() {
192
+ const resp = await this.fetchApi("/object_info", { cache: "no-store" });
193
+ return await resp.json();
194
+ }
195
+
196
+ /**
197
+ *
198
+ * @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
199
+ * @param {object} prompt The prompt data to queue
200
+ */
201
+ async queuePrompt(number, { output, workflow }) {
202
+ const body = {
203
+ client_id: this.clientId,
204
+ prompt: output,
205
+ extra_data: { extra_pnginfo: { workflow } },
206
+ };
207
+
208
+ if (number === -1) {
209
+ body.front = true;
210
+ } else if (number != 0) {
211
+ body.number = number;
212
+ }
213
+
214
+ const res = await this.fetchApi("/prompt", {
215
+ method: "POST",
216
+ headers: {
217
+ "Content-Type": "application/json",
218
+ },
219
+ body: JSON.stringify(body),
220
+ });
221
+
222
+ if (res.status !== 200) {
223
+ throw {
224
+ response: await res.json(),
225
+ };
226
+ }
227
+
228
+ return await res.json();
229
+ }
230
+
231
+ /**
232
+ * Loads a list of items (queue or history)
233
+ * @param {string} type The type of items to load, queue or history
234
+ * @returns The items of the specified type grouped by their status
235
+ */
236
+ async getItems(type) {
237
+ if (type === "queue") {
238
+ return this.getQueue();
239
+ }
240
+ return this.getHistory();
241
+ }
242
+
243
+ /**
244
+ * Gets the current state of the queue
245
+ * @returns The currently running and queued items
246
+ */
247
+ async getQueue() {
248
+ try {
249
+ const res = await this.fetchApi("/queue");
250
+ const data = await res.json();
251
+ return {
252
+ // Running action uses a different endpoint for cancelling
253
+ Running: data.queue_running.map((prompt) => ({
254
+ prompt,
255
+ remove: { name: "Cancel", cb: () => api.interrupt() },
256
+ })),
257
+ Pending: data.queue_pending.map((prompt) => ({ prompt })),
258
+ };
259
+ } catch (error) {
260
+ console.error(error);
261
+ return { Running: [], Pending: [] };
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Gets the prompt execution history
267
+ * @returns Prompt history including node outputs
268
+ */
269
+ async getHistory(max_items=200) {
270
+ try {
271
+ const res = await this.fetchApi(`/history?max_items=${max_items}`);
272
+ return { History: Object.values(await res.json()) };
273
+ } catch (error) {
274
+ console.error(error);
275
+ return { History: [] };
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Gets system & device stats
281
+ * @returns System stats such as python version, OS, per device info
282
+ */
283
+ async getSystemStats() {
284
+ const res = await this.fetchApi("/system_stats");
285
+ return await res.json();
286
+ }
287
+
288
+ /**
289
+ * Sends a POST request to the API
290
+ * @param {*} type The endpoint to post to
291
+ * @param {*} body Optional POST data
292
+ */
293
+ async #postItem(type, body) {
294
+ try {
295
+ await this.fetchApi("/" + type, {
296
+ method: "POST",
297
+ headers: {
298
+ "Content-Type": "application/json",
299
+ },
300
+ body: body ? JSON.stringify(body) : undefined,
301
+ });
302
+ } catch (error) {
303
+ console.error(error);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Deletes an item from the specified list
309
+ * @param {string} type The type of item to delete, queue or history
310
+ * @param {number} id The id of the item to delete
311
+ */
312
+ async deleteItem(type, id) {
313
+ await this.#postItem(type, { delete: [id] });
314
+ }
315
+
316
+ /**
317
+ * Clears the specified list
318
+ * @param {string} type The type of list to clear, queue or history
319
+ */
320
+ async clearItems(type) {
321
+ await this.#postItem(type, { clear: true });
322
+ }
323
+
324
+ /**
325
+ * Interrupts the execution of the running prompt
326
+ */
327
+ async interrupt() {
328
+ await this.#postItem("interrupt", null);
329
+ }
330
+
331
+ /**
332
+ * Gets user configuration data and where data should be stored
333
+ * @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
334
+ */
335
+ async getUserConfig() {
336
+ return (await this.fetchApi("/users")).json();
337
+ }
338
+
339
+ /**
340
+ * Creates a new user
341
+ * @param { string } username
342
+ * @returns The fetch response
343
+ */
344
+ createUser(username) {
345
+ return this.fetchApi("/users", {
346
+ method: "POST",
347
+ headers: {
348
+ "Content-Type": "application/json",
349
+ },
350
+ body: JSON.stringify({ username }),
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Gets all setting values for the current user
356
+ * @returns { Promise<string, unknown> } A dictionary of id -> value
357
+ */
358
+ async getSettings() {
359
+ return (await this.fetchApi("/settings")).json();
360
+ }
361
+
362
+ /**
363
+ * Gets a setting for the current user
364
+ * @param { string } id The id of the setting to fetch
365
+ * @returns { Promise<unknown> } The setting value
366
+ */
367
+ async getSetting(id) {
368
+ return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json();
369
+ }
370
+
371
+ /**
372
+ * Stores a dictionary of settings for the current user
373
+ * @param { Record<string, unknown> } settings Dictionary of setting id -> value to save
374
+ * @returns { Promise<void> }
375
+ */
376
+ async storeSettings(settings) {
377
+ return this.fetchApi(`/settings`, {
378
+ method: "POST",
379
+ body: JSON.stringify(settings)
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Stores a setting for the current user
385
+ * @param { string } id The id of the setting to update
386
+ * @param { unknown } value The value of the setting
387
+ * @returns { Promise<void> }
388
+ */
389
+ async storeSetting(id, value) {
390
+ return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
391
+ method: "POST",
392
+ body: JSON.stringify(value)
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Gets a user data file for the current user
398
+ * @param { string } file The name of the userdata file to load
399
+ * @param { RequestInit } [options]
400
+ * @returns { Promise<Response> } The fetch response object
401
+ */
402
+ async getUserData(file, options) {
403
+ return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
404
+ }
405
+
406
+ /**
407
+ * Stores a user data file for the current user
408
+ * @param { string } file The name of the userdata file to save
409
+ * @param { unknown } data The data to save to the file
410
+ * @param { RequestInit & { overwrite?: boolean, stringify?: boolean, throwOnError?: boolean } } [options]
411
+ * @returns { Promise<Response> }
412
+ */
413
+ async storeUserData(file, data, options = { overwrite: true, stringify: true, throwOnError: true }) {
414
+ const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}?overwrite=${options?.overwrite}`, {
415
+ method: "POST",
416
+ body: options?.stringify ? JSON.stringify(data) : data,
417
+ ...options,
418
+ });
419
+ if (resp.status !== 200 && options?.throwOnError !== false) {
420
+ throw new Error(`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`);
421
+ }
422
+ return resp;
423
+ }
424
+
425
+ /**
426
+ * Deletes a user data file for the current user
427
+ * @param { string } file The name of the userdata file to delete
428
+ */
429
+ async deleteUserData(file) {
430
+ const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
431
+ method: "DELETE",
432
+ });
433
+ if (resp.status !== 204) {
434
+ throw new Error(`Error removing user data file '${file}': ${resp.status} ${(resp).statusText}`);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Move a user data file for the current user
440
+ * @param { string } source The userdata file to move
441
+ * @param { string } dest The destination for the file
442
+ */
443
+ async moveUserData(source, dest, options = { overwrite: false }) {
444
+ const resp = await this.fetchApi(`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`, {
445
+ method: "POST",
446
+ });
447
+ return resp;
448
+ }
449
+
450
+ /**
451
+ * @overload
452
+ * Lists user data files for the current user
453
+ * @param { string } dir The directory in which to list files
454
+ * @param { boolean } [recurse] If the listing should be recursive
455
+ * @param { true } [split] If the paths should be split based on the os path separator
456
+ * @returns { Promise<string[][]>> } The list of split file paths in the format [fullPath, ...splitPath]
457
+ */
458
+ /**
459
+ * @overload
460
+ * Lists user data files for the current user
461
+ * @param { string } dir The directory in which to list files
462
+ * @param { boolean } [recurse] If the listing should be recursive
463
+ * @param { false | undefined } [split] If the paths should be split based on the os path separator
464
+ * @returns { Promise<string[]>> } The list of files
465
+ */
466
+ async listUserData(dir, recurse, split) {
467
+ const resp = await this.fetchApi(
468
+ `/userdata?${new URLSearchParams({
469
+ recurse,
470
+ dir,
471
+ split,
472
+ })}`
473
+ );
474
+ if (resp.status === 404) return [];
475
+ if (resp.status !== 200) {
476
+ throw new Error(`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`);
477
+ }
478
+ return resp.json();
479
+ }
480
+ }
481
+
482
+ export const api = new ComfyApi();
ComfyUI/web/scripts/app.js ADDED
@@ -0,0 +1,2459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComfyLogging } from "./logging.js";
2
+ import { ComfyWidgets, initWidgets } from "./widgets.js";
3
+ import { ComfyUI, $el } from "./ui.js";
4
+ import { api } from "./api.js";
5
+ import { defaultGraph } from "./defaultGraph.js";
6
+ import { getPngMetadata, getWebpMetadata, getFlacMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
7
+ import { addDomClippingSetting } from "./domWidget.js";
8
+ import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js";
9
+ import { ComfyAppMenu } from "./ui/menu/index.js";
10
+ import { getStorageValue, setStorageValue } from "./utils.js";
11
+ import { ComfyWorkflowManager } from "./workflows.js";
12
+ export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview";
13
+
14
+ function sanitizeNodeName(string) {
15
+ let entityMap = {
16
+ '&': '',
17
+ '<': '',
18
+ '>': '',
19
+ '"': '',
20
+ "'": '',
21
+ '`': '',
22
+ '=': ''
23
+ };
24
+ return String(string).replace(/[&<>"'`=]/g, function fromEntityMap (s) {
25
+ return entityMap[s];
26
+ });
27
+ }
28
+
29
+ /**
30
+ * @typedef {import("types/comfy").ComfyExtension} ComfyExtension
31
+ */
32
+
33
+ export class ComfyApp {
34
+ /**
35
+ * List of entries to queue
36
+ * @type {{number: number, batchCount: number}[]}
37
+ */
38
+ #queueItems = [];
39
+ /**
40
+ * If the queue is currently being processed
41
+ * @type {boolean}
42
+ */
43
+ #processingQueue = false;
44
+
45
+ /**
46
+ * Content Clipboard
47
+ * @type {serialized node object}
48
+ */
49
+ static clipspace = null;
50
+ static clipspace_invalidate_handler = null;
51
+ static open_maskeditor = null;
52
+ static clipspace_return_node = null;
53
+
54
+ constructor() {
55
+ this.ui = new ComfyUI(this);
56
+ this.logging = new ComfyLogging(this);
57
+ this.workflowManager = new ComfyWorkflowManager(this);
58
+ this.bodyTop = $el("div.comfyui-body-top", { parent: document.body });
59
+ this.bodyLeft = $el("div.comfyui-body-left", { parent: document.body });
60
+ this.bodyRight = $el("div.comfyui-body-right", { parent: document.body });
61
+ this.bodyBottom = $el("div.comfyui-body-bottom", { parent: document.body });
62
+ this.menu = new ComfyAppMenu(this);
63
+
64
+ /**
65
+ * List of extensions that are registered with the app
66
+ * @type {ComfyExtension[]}
67
+ */
68
+ this.extensions = [];
69
+
70
+ /**
71
+ * Stores the execution output data for each node
72
+ * @type {Record<string, any>}
73
+ */
74
+ this._nodeOutputs = {};
75
+
76
+ /**
77
+ * Stores the preview image data for each node
78
+ * @type {Record<string, Image>}
79
+ */
80
+ this.nodePreviewImages = {};
81
+
82
+ /**
83
+ * If the shift key on the keyboard is pressed
84
+ * @type {boolean}
85
+ */
86
+ this.shiftDown = false;
87
+ }
88
+
89
+ get nodeOutputs() {
90
+ return this._nodeOutputs;
91
+ }
92
+
93
+ set nodeOutputs(value) {
94
+ this._nodeOutputs = value;
95
+ this.#invokeExtensions("onNodeOutputsUpdated", value);
96
+ }
97
+
98
+ getPreviewFormatParam() {
99
+ let preview_format = this.ui.settings.getSettingValue("Comfy.PreviewFormat");
100
+ if(preview_format)
101
+ return `&preview=${preview_format}`;
102
+ else
103
+ return "";
104
+ }
105
+
106
+ getRandParam() {
107
+ return "&rand=" + Math.random();
108
+ }
109
+
110
+ static isImageNode(node) {
111
+ return node.imgs || (node && node.widgets && node.widgets.findIndex(obj => obj.name === 'image') >= 0);
112
+ }
113
+
114
+ static onClipspaceEditorSave() {
115
+ if(ComfyApp.clipspace_return_node) {
116
+ ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node);
117
+ }
118
+ }
119
+
120
+ static onClipspaceEditorClosed() {
121
+ ComfyApp.clipspace_return_node = null;
122
+ }
123
+
124
+ static copyToClipspace(node) {
125
+ var widgets = null;
126
+ if(node.widgets) {
127
+ widgets = node.widgets.map(({ type, name, value }) => ({ type, name, value }));
128
+ }
129
+
130
+ var imgs = undefined;
131
+ var orig_imgs = undefined;
132
+ if(node.imgs != undefined) {
133
+ imgs = [];
134
+ orig_imgs = [];
135
+
136
+ for (let i = 0; i < node.imgs.length; i++) {
137
+ imgs[i] = new Image();
138
+ imgs[i].src = node.imgs[i].src;
139
+ orig_imgs[i] = imgs[i];
140
+ }
141
+ }
142
+
143
+ var selectedIndex = 0;
144
+ if(node.imageIndex) {
145
+ selectedIndex = node.imageIndex;
146
+ }
147
+
148
+ ComfyApp.clipspace = {
149
+ 'widgets': widgets,
150
+ 'imgs': imgs,
151
+ 'original_imgs': orig_imgs,
152
+ 'images': node.images,
153
+ 'selectedIndex': selectedIndex,
154
+ 'img_paste_mode': 'selected' // reset to default im_paste_mode state on copy action
155
+ };
156
+
157
+ ComfyApp.clipspace_return_node = null;
158
+
159
+ if(ComfyApp.clipspace_invalidate_handler) {
160
+ ComfyApp.clipspace_invalidate_handler();
161
+ }
162
+ }
163
+
164
+ static pasteFromClipspace(node) {
165
+ if(ComfyApp.clipspace) {
166
+ // image paste
167
+ if(ComfyApp.clipspace.imgs && node.imgs) {
168
+ if(node.images && ComfyApp.clipspace.images) {
169
+ if(ComfyApp.clipspace['img_paste_mode'] == 'selected') {
170
+ node.images = [ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']]];
171
+ }
172
+ else {
173
+ node.images = ComfyApp.clipspace.images;
174
+ }
175
+
176
+ if(app.nodeOutputs[node.id + ""])
177
+ app.nodeOutputs[node.id + ""].images = node.images;
178
+ }
179
+
180
+ if(ComfyApp.clipspace.imgs) {
181
+ // deep-copy to cut link with clipspace
182
+ if(ComfyApp.clipspace['img_paste_mode'] == 'selected') {
183
+ const img = new Image();
184
+ img.src = ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src;
185
+ node.imgs = [img];
186
+ node.imageIndex = 0;
187
+ }
188
+ else {
189
+ const imgs = [];
190
+ for(let i=0; i<ComfyApp.clipspace.imgs.length; i++) {
191
+ imgs[i] = new Image();
192
+ imgs[i].src = ComfyApp.clipspace.imgs[i].src;
193
+ node.imgs = imgs;
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ if(node.widgets) {
200
+ if(ComfyApp.clipspace.images) {
201
+ const clip_image = ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']];
202
+ const index = node.widgets.findIndex(obj => obj.name === 'image');
203
+ if(index >= 0) {
204
+ if(node.widgets[index].type != 'image' && typeof node.widgets[index].value == "string" && clip_image.filename) {
205
+ node.widgets[index].value = (clip_image.subfolder?clip_image.subfolder+'/':'') + clip_image.filename + (clip_image.type?` [${clip_image.type}]`:'');
206
+ }
207
+ else {
208
+ node.widgets[index].value = clip_image;
209
+ }
210
+ }
211
+ }
212
+ if(ComfyApp.clipspace.widgets) {
213
+ ComfyApp.clipspace.widgets.forEach(({ type, name, value }) => {
214
+ const prop = Object.values(node.widgets).find(obj => obj.type === type && obj.name === name);
215
+ if (prop && prop.type != 'button') {
216
+ if(prop.type != 'image' && typeof prop.value == "string" && value.filename) {
217
+ prop.value = (value.subfolder?value.subfolder+'/':'') + value.filename + (value.type?` [${value.type}]`:'');
218
+ }
219
+ else {
220
+ prop.value = value;
221
+ prop.callback(value);
222
+ }
223
+ }
224
+ });
225
+ }
226
+ }
227
+
228
+ app.graph.setDirtyCanvas(true);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Invoke an extension callback
234
+ * @param {keyof ComfyExtension} method The extension callback to execute
235
+ * @param {any[]} args Any arguments to pass to the callback
236
+ * @returns
237
+ */
238
+ #invokeExtensions(method, ...args) {
239
+ let results = [];
240
+ for (const ext of this.extensions) {
241
+ if (method in ext) {
242
+ try {
243
+ results.push(ext[method](...args, this));
244
+ } catch (error) {
245
+ console.error(
246
+ `Error calling extension '${ext.name}' method '${method}'`,
247
+ { error },
248
+ { extension: ext },
249
+ { args }
250
+ );
251
+ }
252
+ }
253
+ }
254
+ return results;
255
+ }
256
+
257
+ /**
258
+ * Invoke an async extension callback
259
+ * Each callback will be invoked concurrently
260
+ * @param {string} method The extension callback to execute
261
+ * @param {...any} args Any arguments to pass to the callback
262
+ * @returns
263
+ */
264
+ async #invokeExtensionsAsync(method, ...args) {
265
+ return await Promise.all(
266
+ this.extensions.map(async (ext) => {
267
+ if (method in ext) {
268
+ try {
269
+ return await ext[method](...args, this);
270
+ } catch (error) {
271
+ console.error(
272
+ `Error calling extension '${ext.name}' method '${method}'`,
273
+ { error },
274
+ { extension: ext },
275
+ { args }
276
+ );
277
+ }
278
+ }
279
+ })
280
+ );
281
+ }
282
+
283
+ #addRestoreWorkflowView() {
284
+ const serialize = LGraph.prototype.serialize;
285
+ const self = this;
286
+ LGraph.prototype.serialize = function() {
287
+ const workflow = serialize.apply(this, arguments);
288
+
289
+ // Store the drag & scale info in the serialized workflow if the setting is enabled
290
+ if (self.enableWorkflowViewRestore.value) {
291
+ if (!workflow.extra) {
292
+ workflow.extra = {};
293
+ }
294
+ workflow.extra.ds = {
295
+ scale: self.canvas.ds.scale,
296
+ offset: self.canvas.ds.offset,
297
+ };
298
+ } else if (workflow.extra?.ds) {
299
+ // Clear any old view data
300
+ delete workflow.extra.ds;
301
+ }
302
+
303
+ return workflow;
304
+ }
305
+ this.enableWorkflowViewRestore = this.ui.settings.addSetting({
306
+ id: "Comfy.EnableWorkflowViewRestore",
307
+ name: "Save and restore canvas position and zoom level in workflows",
308
+ type: "boolean",
309
+ defaultValue: true
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Adds special context menu handling for nodes
315
+ * e.g. this adds Open Image functionality for nodes that show images
316
+ * @param {*} node The node to add the menu handler
317
+ */
318
+ #addNodeContextMenuHandler(node) {
319
+ function getCopyImageOption(img) {
320
+ if (typeof window.ClipboardItem === "undefined") return [];
321
+ return [
322
+ {
323
+ content: "Copy Image",
324
+ callback: async () => {
325
+ const url = new URL(img.src);
326
+ url.searchParams.delete("preview");
327
+
328
+ const writeImage = async (blob) => {
329
+ await navigator.clipboard.write([
330
+ new ClipboardItem({
331
+ [blob.type]: blob,
332
+ }),
333
+ ]);
334
+ };
335
+
336
+ try {
337
+ const data = await fetch(url);
338
+ const blob = await data.blob();
339
+ try {
340
+ await writeImage(blob);
341
+ } catch (error) {
342
+ // Chrome seems to only support PNG on write, convert and try again
343
+ if (blob.type !== "image/png") {
344
+ const canvas = $el("canvas", {
345
+ width: img.naturalWidth,
346
+ height: img.naturalHeight,
347
+ });
348
+ const ctx = canvas.getContext("2d");
349
+ let image;
350
+ if (typeof window.createImageBitmap === "undefined") {
351
+ image = new Image();
352
+ const p = new Promise((resolve, reject) => {
353
+ image.onload = resolve;
354
+ image.onerror = reject;
355
+ }).finally(() => {
356
+ URL.revokeObjectURL(image.src);
357
+ });
358
+ image.src = URL.createObjectURL(blob);
359
+ await p;
360
+ } else {
361
+ image = await createImageBitmap(blob);
362
+ }
363
+ try {
364
+ ctx.drawImage(image, 0, 0);
365
+ canvas.toBlob(writeImage, "image/png");
366
+ } finally {
367
+ if (typeof image.close === "function") {
368
+ image.close();
369
+ }
370
+ }
371
+
372
+ return;
373
+ }
374
+ throw error;
375
+ }
376
+ } catch (error) {
377
+ alert("Error copying image: " + (error.message ?? error));
378
+ }
379
+ },
380
+ },
381
+ ];
382
+ }
383
+
384
+ node.prototype.getExtraMenuOptions = function (_, options) {
385
+ if (this.imgs) {
386
+ // If this node has images then we add an open in new tab item
387
+ let img;
388
+ if (this.imageIndex != null) {
389
+ // An image is selected so select that
390
+ img = this.imgs[this.imageIndex];
391
+ } else if (this.overIndex != null) {
392
+ // No image is selected but one is hovered
393
+ img = this.imgs[this.overIndex];
394
+ }
395
+ if (img) {
396
+ options.unshift(
397
+ {
398
+ content: "Open Image",
399
+ callback: () => {
400
+ let url = new URL(img.src);
401
+ url.searchParams.delete("preview");
402
+ window.open(url, "_blank");
403
+ },
404
+ },
405
+ ...getCopyImageOption(img),
406
+ {
407
+ content: "Save Image",
408
+ callback: () => {
409
+ const a = document.createElement("a");
410
+ let url = new URL(img.src);
411
+ url.searchParams.delete("preview");
412
+ a.href = url;
413
+ a.setAttribute("download", new URLSearchParams(url.search).get("filename"));
414
+ document.body.append(a);
415
+ a.click();
416
+ requestAnimationFrame(() => a.remove());
417
+ },
418
+ }
419
+ );
420
+ }
421
+ }
422
+
423
+ options.push({
424
+ content: "Bypass",
425
+ callback: (obj) => {
426
+ if (this.mode === 4) this.mode = 0;
427
+ else this.mode = 4;
428
+ this.graph.change();
429
+ },
430
+ });
431
+
432
+ // prevent conflict of clipspace content
433
+ if (!ComfyApp.clipspace_return_node) {
434
+ options.push({
435
+ content: "Copy (Clipspace)",
436
+ callback: (obj) => {
437
+ ComfyApp.copyToClipspace(this);
438
+ },
439
+ });
440
+
441
+ if (ComfyApp.clipspace != null) {
442
+ options.push({
443
+ content: "Paste (Clipspace)",
444
+ callback: () => {
445
+ ComfyApp.pasteFromClipspace(this);
446
+ },
447
+ });
448
+ }
449
+
450
+ if (ComfyApp.isImageNode(this)) {
451
+ options.push({
452
+ content: "Open in MaskEditor",
453
+ callback: (obj) => {
454
+ ComfyApp.copyToClipspace(this);
455
+ ComfyApp.clipspace_return_node = this;
456
+ ComfyApp.open_maskeditor();
457
+ },
458
+ });
459
+ }
460
+ }
461
+ };
462
+ }
463
+
464
+ #addNodeKeyHandler(node) {
465
+ const app = this;
466
+ const origNodeOnKeyDown = node.prototype.onKeyDown;
467
+
468
+ node.prototype.onKeyDown = function(e) {
469
+ if (origNodeOnKeyDown && origNodeOnKeyDown.apply(this, e) === false) {
470
+ return false;
471
+ }
472
+
473
+ if (this.flags.collapsed || !this.imgs || this.imageIndex === null) {
474
+ return;
475
+ }
476
+
477
+ let handled = false;
478
+
479
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
480
+ if (e.key === "ArrowLeft") {
481
+ this.imageIndex -= 1;
482
+ } else if (e.key === "ArrowRight") {
483
+ this.imageIndex += 1;
484
+ }
485
+ this.imageIndex %= this.imgs.length;
486
+
487
+ if (this.imageIndex < 0) {
488
+ this.imageIndex = this.imgs.length + this.imageIndex;
489
+ }
490
+ handled = true;
491
+ } else if (e.key === "Escape") {
492
+ this.imageIndex = null;
493
+ handled = true;
494
+ }
495
+
496
+ if (handled === true) {
497
+ e.preventDefault();
498
+ e.stopImmediatePropagation();
499
+ return false;
500
+ }
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Adds Custom drawing logic for nodes
506
+ * e.g. Draws images and handles thumbnail navigation on nodes that output images
507
+ * @param {*} node The node to add the draw handler
508
+ */
509
+ #addDrawBackgroundHandler(node) {
510
+ const app = this;
511
+
512
+ function getImageTop(node) {
513
+ let shiftY;
514
+ if (node.imageOffset != null) {
515
+ shiftY = node.imageOffset;
516
+ } else {
517
+ if (node.widgets?.length) {
518
+ const w = node.widgets[node.widgets.length - 1];
519
+ shiftY = w.last_y;
520
+ if (w.computeSize) {
521
+ shiftY += w.computeSize()[1] + 4;
522
+ }
523
+ else if(w.computedHeight) {
524
+ shiftY += w.computedHeight;
525
+ }
526
+ else {
527
+ shiftY += LiteGraph.NODE_WIDGET_HEIGHT + 4;
528
+ }
529
+ } else {
530
+ shiftY = node.computeSize()[1];
531
+ }
532
+ }
533
+ return shiftY;
534
+ }
535
+
536
+ node.prototype.setSizeForImage = function (force) {
537
+ if(!force && this.animatedImages) return;
538
+
539
+ if (this.inputHeight || this.freeWidgetSpace > 210) {
540
+ this.setSize(this.size);
541
+ return;
542
+ }
543
+ const minHeight = getImageTop(this) + 220;
544
+ if (this.size[1] < minHeight) {
545
+ this.setSize([this.size[0], minHeight]);
546
+ }
547
+ };
548
+
549
+ node.prototype.onDrawBackground = function (ctx) {
550
+ if (!this.flags.collapsed) {
551
+ let imgURLs = []
552
+ let imagesChanged = false
553
+
554
+ const output = app.nodeOutputs[this.id + ""];
555
+ if (output?.images) {
556
+ this.animatedImages = output?.animated?.find(Boolean);
557
+ if (this.images !== output.images) {
558
+ this.images = output.images;
559
+ imagesChanged = true;
560
+ imgURLs = imgURLs.concat(
561
+ output.images.map((params) => {
562
+ return api.apiURL(
563
+ "/view?" +
564
+ new URLSearchParams(params).toString() +
565
+ (this.animatedImages ? "" : app.getPreviewFormatParam()) + app.getRandParam()
566
+ );
567
+ })
568
+ );
569
+ }
570
+ }
571
+
572
+ const preview = app.nodePreviewImages[this.id + ""]
573
+ if (this.preview !== preview) {
574
+ this.preview = preview
575
+ imagesChanged = true;
576
+ if (preview != null) {
577
+ imgURLs.push(preview);
578
+ }
579
+ }
580
+
581
+ if (imagesChanged) {
582
+ this.imageIndex = null;
583
+ if (imgURLs.length > 0) {
584
+ Promise.all(
585
+ imgURLs.map((src) => {
586
+ return new Promise((r) => {
587
+ const img = new Image();
588
+ img.onload = () => r(img);
589
+ img.onerror = () => r(null);
590
+ img.src = src
591
+ });
592
+ })
593
+ ).then((imgs) => {
594
+ if ((!output || this.images === output.images) && (!preview || this.preview === preview)) {
595
+ this.imgs = imgs.filter(Boolean);
596
+ this.setSizeForImage?.();
597
+ app.graph.setDirtyCanvas(true);
598
+ }
599
+ });
600
+ }
601
+ else {
602
+ this.imgs = null;
603
+ }
604
+ }
605
+
606
+ function calculateGrid(w, h, n) {
607
+ let columns, rows, cellsize;
608
+
609
+ if (w > h) {
610
+ cellsize = h;
611
+ columns = Math.ceil(w / cellsize);
612
+ rows = Math.ceil(n / columns);
613
+ } else {
614
+ cellsize = w;
615
+ rows = Math.ceil(h / cellsize);
616
+ columns = Math.ceil(n / rows);
617
+ }
618
+
619
+ while (columns * rows < n) {
620
+ cellsize++;
621
+ if (w >= h) {
622
+ columns = Math.ceil(w / cellsize);
623
+ rows = Math.ceil(n / columns);
624
+ } else {
625
+ rows = Math.ceil(h / cellsize);
626
+ columns = Math.ceil(n / rows);
627
+ }
628
+ }
629
+
630
+ const cell_size = Math.min(w/columns, h/rows);
631
+ return {cell_size, columns, rows};
632
+ }
633
+
634
+ function is_all_same_aspect_ratio(imgs) {
635
+ // assume: imgs.length >= 2
636
+ let ratio = imgs[0].naturalWidth/imgs[0].naturalHeight;
637
+
638
+ for(let i=1; i<imgs.length; i++) {
639
+ let this_ratio = imgs[i].naturalWidth/imgs[i].naturalHeight;
640
+ if(ratio != this_ratio)
641
+ return false;
642
+ }
643
+
644
+ return true;
645
+ }
646
+
647
+ if (this.imgs?.length) {
648
+ const widgetIdx = this.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET);
649
+
650
+ if(this.animatedImages) {
651
+ // Instead of using the canvas we'll use a IMG
652
+ if(widgetIdx > -1) {
653
+ // Replace content
654
+ const widget = this.widgets[widgetIdx];
655
+ widget.options.host.updateImages(this.imgs);
656
+ } else {
657
+ const host = createImageHost(this);
658
+ this.setSizeForImage(true);
659
+ const widget = this.addDOMWidget(ANIM_PREVIEW_WIDGET, "img", host.el, {
660
+ host,
661
+ getHeight: host.getHeight,
662
+ onDraw: host.onDraw,
663
+ hideOnZoom: false
664
+ });
665
+ widget.serializeValue = () => undefined;
666
+ widget.options.host.updateImages(this.imgs);
667
+ }
668
+ return;
669
+ }
670
+
671
+ if (widgetIdx > -1) {
672
+ this.widgets[widgetIdx].onRemove?.();
673
+ this.widgets.splice(widgetIdx, 1);
674
+ }
675
+
676
+ const canvas = app.graph.list_of_graphcanvas[0];
677
+ const mouse = canvas.graph_mouse;
678
+ if (!canvas.pointer_is_down && this.pointerDown) {
679
+ if (mouse[0] === this.pointerDown.pos[0] && mouse[1] === this.pointerDown.pos[1]) {
680
+ this.imageIndex = this.pointerDown.index;
681
+ }
682
+ this.pointerDown = null;
683
+ }
684
+
685
+ let imageIndex = this.imageIndex;
686
+ const numImages = this.imgs.length;
687
+ if (numImages === 1 && !imageIndex) {
688
+ this.imageIndex = imageIndex = 0;
689
+ }
690
+
691
+ const top = getImageTop(this);
692
+ var shiftY = top;
693
+
694
+ let dw = this.size[0];
695
+ let dh = this.size[1];
696
+ dh -= shiftY;
697
+
698
+ if (imageIndex == null) {
699
+ var cellWidth, cellHeight, shiftX, cell_padding, cols;
700
+
701
+ const compact_mode = is_all_same_aspect_ratio(this.imgs);
702
+ if(!compact_mode) {
703
+ // use rectangle cell style and border line
704
+ cell_padding = 2;
705
+ const { cell_size, columns, rows } = calculateGrid(dw, dh, numImages);
706
+ cols = columns;
707
+
708
+ cellWidth = cell_size;
709
+ cellHeight = cell_size;
710
+ shiftX = (dw-cell_size*cols)/2;
711
+ shiftY = (dh-cell_size*rows)/2 + top;
712
+ }
713
+ else {
714
+ cell_padding = 0;
715
+ ({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(this.imgs, dw, dh));
716
+ }
717
+
718
+ let anyHovered = false;
719
+ this.imageRects = [];
720
+ for (let i = 0; i < numImages; i++) {
721
+ const img = this.imgs[i];
722
+ const row = Math.floor(i / cols);
723
+ const col = i % cols;
724
+ const x = col * cellWidth + shiftX;
725
+ const y = row * cellHeight + shiftY;
726
+ if (!anyHovered) {
727
+ anyHovered = LiteGraph.isInsideRectangle(
728
+ mouse[0],
729
+ mouse[1],
730
+ x + this.pos[0],
731
+ y + this.pos[1],
732
+ cellWidth,
733
+ cellHeight
734
+ );
735
+ if (anyHovered) {
736
+ this.overIndex = i;
737
+ let value = 110;
738
+ if (canvas.pointer_is_down) {
739
+ if (!this.pointerDown || this.pointerDown.index !== i) {
740
+ this.pointerDown = { index: i, pos: [...mouse] };
741
+ }
742
+ value = 125;
743
+ }
744
+ ctx.filter = `contrast(${value}%) brightness(${value}%)`;
745
+ canvas.canvas.style.cursor = "pointer";
746
+ }
747
+ }
748
+ this.imageRects.push([x, y, cellWidth, cellHeight]);
749
+
750
+ let wratio = cellWidth/img.width;
751
+ let hratio = cellHeight/img.height;
752
+ var ratio = Math.min(wratio, hratio);
753
+
754
+ let imgHeight = ratio * img.height;
755
+ let imgY = row * cellHeight + shiftY + (cellHeight - imgHeight)/2;
756
+ let imgWidth = ratio * img.width;
757
+ let imgX = col * cellWidth + shiftX + (cellWidth - imgWidth)/2;
758
+
759
+ ctx.drawImage(img, imgX+cell_padding, imgY+cell_padding, imgWidth-cell_padding*2, imgHeight-cell_padding*2);
760
+ if(!compact_mode) {
761
+ // rectangle cell and border line style
762
+ ctx.strokeStyle = "#8F8F8F";
763
+ ctx.lineWidth = 1;
764
+ ctx.strokeRect(x+cell_padding, y+cell_padding, cellWidth-cell_padding*2, cellHeight-cell_padding*2);
765
+ }
766
+
767
+ ctx.filter = "none";
768
+ }
769
+
770
+ if (!anyHovered) {
771
+ this.pointerDown = null;
772
+ this.overIndex = null;
773
+ }
774
+ } else {
775
+ // Draw individual
776
+ let w = this.imgs[imageIndex].naturalWidth;
777
+ let h = this.imgs[imageIndex].naturalHeight;
778
+
779
+ const scaleX = dw / w;
780
+ const scaleY = dh / h;
781
+ const scale = Math.min(scaleX, scaleY, 1);
782
+
783
+ w *= scale;
784
+ h *= scale;
785
+
786
+ let x = (dw - w) / 2;
787
+ let y = (dh - h) / 2 + shiftY;
788
+ ctx.drawImage(this.imgs[imageIndex], x, y, w, h);
789
+
790
+ const drawButton = (x, y, sz, text) => {
791
+ const hovered = LiteGraph.isInsideRectangle(mouse[0], mouse[1], x + this.pos[0], y + this.pos[1], sz, sz);
792
+ let fill = "#333";
793
+ let textFill = "#fff";
794
+ let isClicking = false;
795
+ if (hovered) {
796
+ canvas.canvas.style.cursor = "pointer";
797
+ if (canvas.pointer_is_down) {
798
+ fill = "#1e90ff";
799
+ isClicking = true;
800
+ } else {
801
+ fill = "#eee";
802
+ textFill = "#000";
803
+ }
804
+ } else {
805
+ this.pointerWasDown = null;
806
+ }
807
+
808
+ ctx.fillStyle = fill;
809
+ ctx.beginPath();
810
+ ctx.roundRect(x, y, sz, sz, [4]);
811
+ ctx.fill();
812
+ ctx.fillStyle = textFill;
813
+ ctx.font = "12px Arial";
814
+ ctx.textAlign = "center";
815
+ ctx.fillText(text, x + 15, y + 20);
816
+
817
+ return isClicking;
818
+ };
819
+
820
+ if (numImages > 1) {
821
+ if (drawButton(dw - 40, dh + top - 40, 30, `${this.imageIndex + 1}/${numImages}`)) {
822
+ let i = this.imageIndex + 1 >= numImages ? 0 : this.imageIndex + 1;
823
+ if (!this.pointerDown || !this.pointerDown.index === i) {
824
+ this.pointerDown = { index: i, pos: [...mouse] };
825
+ }
826
+ }
827
+
828
+ if (drawButton(dw - 40, top + 10, 30, `x`)) {
829
+ if (!this.pointerDown || !this.pointerDown.index === null) {
830
+ this.pointerDown = { index: null, pos: [...mouse] };
831
+ }
832
+ }
833
+ }
834
+ }
835
+ }
836
+ }
837
+ };
838
+ }
839
+
840
+ /**
841
+ * Adds a handler allowing drag+drop of files onto the window to load workflows
842
+ */
843
+ #addDropHandler() {
844
+ // Get prompt from dropped PNG or json
845
+ document.addEventListener("drop", async (event) => {
846
+ event.preventDefault();
847
+ event.stopPropagation();
848
+
849
+ const n = this.dragOverNode;
850
+ this.dragOverNode = null;
851
+ // Node handles file drop, we dont use the built in onDropFile handler as its buggy
852
+ // If you drag multiple files it will call it multiple times with the same file
853
+ if (n && n.onDragDrop && (await n.onDragDrop(event))) {
854
+ return;
855
+ }
856
+ // Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
857
+ if (event.dataTransfer.files.length && event.dataTransfer.files[0].type !== "image/bmp") {
858
+ await this.handleFile(event.dataTransfer.files[0]);
859
+ } else {
860
+ // Try loading the first URI in the transfer list
861
+ const validTypes = ["text/uri-list", "text/x-moz-url"];
862
+ const match = [...event.dataTransfer.types].find((t) => validTypes.find(v => t === v));
863
+ if (match) {
864
+ const uri = event.dataTransfer.getData(match)?.split("\n")?.[0];
865
+ if (uri) {
866
+ await this.handleFile(await (await fetch(uri)).blob());
867
+ }
868
+ }
869
+ }
870
+ });
871
+
872
+ // Always clear over node on drag leave
873
+ this.canvasEl.addEventListener("dragleave", async () => {
874
+ if (this.dragOverNode) {
875
+ this.dragOverNode = null;
876
+ this.graph.setDirtyCanvas(false, true);
877
+ }
878
+ });
879
+
880
+ // Add handler for dropping onto a specific node
881
+ this.canvasEl.addEventListener(
882
+ "dragover",
883
+ (e) => {
884
+ this.canvas.adjustMouseEvent(e);
885
+ const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY);
886
+ if (node) {
887
+ if (node.onDragOver && node.onDragOver(e)) {
888
+ this.dragOverNode = node;
889
+
890
+ // dragover event is fired very frequently, run this on an animation frame
891
+ requestAnimationFrame(() => {
892
+ this.graph.setDirtyCanvas(false, true);
893
+ });
894
+ return;
895
+ }
896
+ }
897
+ this.dragOverNode = null;
898
+ },
899
+ false
900
+ );
901
+ }
902
+
903
+ /**
904
+ * Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
905
+ */
906
+ #addPasteHandler() {
907
+ document.addEventListener("paste", async (e) => {
908
+ // ctrl+shift+v is used to paste nodes with connections
909
+ // this is handled by litegraph
910
+ if(this.shiftDown) return;
911
+
912
+ let data = (e.clipboardData || window.clipboardData);
913
+ const items = data.items;
914
+
915
+ // Look for image paste data
916
+ for (const item of items) {
917
+ if (item.type.startsWith('image/')) {
918
+ var imageNode = null;
919
+
920
+ // If an image node is selected, paste into it
921
+ if (this.canvas.current_node &&
922
+ this.canvas.current_node.is_selected &&
923
+ ComfyApp.isImageNode(this.canvas.current_node)) {
924
+ imageNode = this.canvas.current_node;
925
+ }
926
+
927
+ // No image node selected: add a new one
928
+ if (!imageNode) {
929
+ const newNode = LiteGraph.createNode("LoadImage");
930
+ newNode.pos = [...this.canvas.graph_mouse];
931
+ imageNode = this.graph.add(newNode);
932
+ this.graph.change();
933
+ }
934
+ const blob = item.getAsFile();
935
+ imageNode.pasteFile(blob);
936
+ return;
937
+ }
938
+ }
939
+
940
+ // No image found. Look for node data
941
+ data = data.getData("text/plain");
942
+ let workflow;
943
+ try {
944
+ data = data.slice(data.indexOf("{"));
945
+ workflow = JSON.parse(data);
946
+ } catch (err) {
947
+ try {
948
+ data = data.slice(data.indexOf("workflow\n"));
949
+ data = data.slice(data.indexOf("{"));
950
+ workflow = JSON.parse(data);
951
+ } catch (error) {}
952
+ }
953
+
954
+ if (workflow && workflow.version && workflow.nodes && workflow.extra) {
955
+ await this.loadGraphData(workflow);
956
+ }
957
+ else {
958
+ if (e.target.type === "text" || e.target.type === "textarea") {
959
+ return;
960
+ }
961
+
962
+ // Litegraph default paste
963
+ this.canvas.pasteFromClipboard();
964
+ }
965
+
966
+
967
+ });
968
+ }
969
+
970
+
971
+ /**
972
+ * Adds a handler on copy that serializes selected nodes to JSON
973
+ */
974
+ #addCopyHandler() {
975
+ document.addEventListener("copy", (e) => {
976
+ if (e.target.type === "text" || e.target.type === "textarea") {
977
+ // Default system copy
978
+ return;
979
+ }
980
+
981
+ // copy nodes and clear clipboard
982
+ if (e.target.className === "litegraph" && this.canvas.selected_nodes) {
983
+ this.canvas.copyToClipboard();
984
+ e.clipboardData.setData('text', ' '); //clearData doesn't remove images from clipboard
985
+ e.preventDefault();
986
+ e.stopImmediatePropagation();
987
+ return false;
988
+ }
989
+ });
990
+ }
991
+
992
+
993
+ /**
994
+ * Handle mouse
995
+ *
996
+ * Move group by header
997
+ */
998
+ #addProcessMouseHandler() {
999
+ const self = this;
1000
+
1001
+ const origProcessMouseDown = LGraphCanvas.prototype.processMouseDown;
1002
+ LGraphCanvas.prototype.processMouseDown = function(e) {
1003
+ // prepare for ctrl+shift drag: zoom start
1004
+ if(e.ctrlKey && e.shiftKey && e.buttons) {
1005
+ self.zoom_drag_start = [e.x, e.y, this.ds.scale];
1006
+ return;
1007
+ }
1008
+
1009
+ const res = origProcessMouseDown.apply(this, arguments);
1010
+
1011
+ this.selected_group_moving = false;
1012
+
1013
+ if (this.selected_group && !this.selected_group_resizing) {
1014
+ var font_size =
1015
+ this.selected_group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE;
1016
+ var height = font_size * 1.4;
1017
+
1018
+ // Move group by header
1019
+ if (LiteGraph.isInsideRectangle(e.canvasX, e.canvasY, this.selected_group.pos[0], this.selected_group.pos[1], this.selected_group.size[0], height)) {
1020
+ this.selected_group_moving = true;
1021
+ }
1022
+ }
1023
+
1024
+ return res;
1025
+ }
1026
+
1027
+ const origProcessMouseMove = LGraphCanvas.prototype.processMouseMove;
1028
+ LGraphCanvas.prototype.processMouseMove = function(e) {
1029
+ // handle ctrl+shift drag
1030
+ if(e.ctrlKey && e.shiftKey && self.zoom_drag_start) {
1031
+ // stop canvas zoom action
1032
+ if(!e.buttons) {
1033
+ self.zoom_drag_start = null;
1034
+ return;
1035
+ }
1036
+
1037
+ // calculate delta
1038
+ let deltaY = e.y - self.zoom_drag_start[1];
1039
+ let startScale = self.zoom_drag_start[2];
1040
+
1041
+ let scale = startScale - deltaY/100;
1042
+
1043
+ this.ds.changeScale(scale, [this.ds.element.width/2, this.ds.element.height/2]);
1044
+ this.graph.change();
1045
+
1046
+ return;
1047
+ }
1048
+
1049
+ const orig_selected_group = this.selected_group;
1050
+
1051
+ if (this.selected_group && !this.selected_group_resizing && !this.selected_group_moving) {
1052
+ this.selected_group = null;
1053
+ }
1054
+
1055
+ const res = origProcessMouseMove.apply(this, arguments);
1056
+
1057
+ if (orig_selected_group && !this.selected_group_resizing && !this.selected_group_moving) {
1058
+ this.selected_group = orig_selected_group;
1059
+ }
1060
+
1061
+ return res;
1062
+ };
1063
+ }
1064
+
1065
+ /**
1066
+ * Handle keypress
1067
+ *
1068
+ * Ctrl + M mute/unmute selected nodes
1069
+ */
1070
+ #addProcessKeyHandler() {
1071
+ const self = this;
1072
+ const origProcessKey = LGraphCanvas.prototype.processKey;
1073
+ LGraphCanvas.prototype.processKey = function(e) {
1074
+ if (!this.graph) {
1075
+ return;
1076
+ }
1077
+
1078
+ var block_default = false;
1079
+
1080
+ if (e.target.localName == "input") {
1081
+ return;
1082
+ }
1083
+
1084
+ if (e.type == "keydown" && !e.repeat) {
1085
+
1086
+ // Ctrl + M mute/unmute
1087
+ if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
1088
+ if (this.selected_nodes) {
1089
+ for (var i in this.selected_nodes) {
1090
+ if (this.selected_nodes[i].mode === 2) { // never
1091
+ this.selected_nodes[i].mode = 0; // always
1092
+ } else {
1093
+ this.selected_nodes[i].mode = 2; // never
1094
+ }
1095
+ }
1096
+ }
1097
+ block_default = true;
1098
+ }
1099
+
1100
+ // Ctrl + B bypass
1101
+ if (e.key === 'b' && (e.metaKey || e.ctrlKey)) {
1102
+ if (this.selected_nodes) {
1103
+ for (var i in this.selected_nodes) {
1104
+ if (this.selected_nodes[i].mode === 4) { // never
1105
+ this.selected_nodes[i].mode = 0; // always
1106
+ } else {
1107
+ this.selected_nodes[i].mode = 4; // never
1108
+ }
1109
+ }
1110
+ }
1111
+ block_default = true;
1112
+ }
1113
+
1114
+ // Alt + C collapse/uncollapse
1115
+ if (e.key === 'c' && e.altKey) {
1116
+ if (this.selected_nodes) {
1117
+ for (var i in this.selected_nodes) {
1118
+ this.selected_nodes[i].collapse()
1119
+ }
1120
+ }
1121
+ block_default = true;
1122
+ }
1123
+
1124
+ // Ctrl+C Copy
1125
+ if ((e.key === 'c') && (e.metaKey || e.ctrlKey)) {
1126
+ // Trigger onCopy
1127
+ return true;
1128
+ }
1129
+
1130
+ // Ctrl+V Paste
1131
+ if ((e.key === 'v' || e.key == 'V') && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
1132
+ // Trigger onPaste
1133
+ return true;
1134
+ }
1135
+
1136
+ if((e.key === '+') && e.altKey) {
1137
+ block_default = true;
1138
+ let scale = this.ds.scale * 1.1;
1139
+ this.ds.changeScale(scale, [this.ds.element.width/2, this.ds.element.height/2]);
1140
+ this.graph.change();
1141
+ }
1142
+
1143
+ if((e.key === '-') && e.altKey) {
1144
+ block_default = true;
1145
+ let scale = this.ds.scale * 1 / 1.1;
1146
+ this.ds.changeScale(scale, [this.ds.element.width/2, this.ds.element.height/2]);
1147
+ this.graph.change();
1148
+ }
1149
+ }
1150
+
1151
+ this.graph.change();
1152
+
1153
+ if (block_default) {
1154
+ e.preventDefault();
1155
+ e.stopImmediatePropagation();
1156
+ return false;
1157
+ }
1158
+
1159
+ // Fall through to Litegraph defaults
1160
+ return origProcessKey.apply(this, arguments);
1161
+ };
1162
+ }
1163
+
1164
+ /**
1165
+ * Draws group header bar
1166
+ */
1167
+ #addDrawGroupsHandler() {
1168
+ const self = this;
1169
+
1170
+ const origDrawGroups = LGraphCanvas.prototype.drawGroups;
1171
+ LGraphCanvas.prototype.drawGroups = function(canvas, ctx) {
1172
+ if (!this.graph) {
1173
+ return;
1174
+ }
1175
+
1176
+ var groups = this.graph._groups;
1177
+
1178
+ ctx.save();
1179
+ ctx.globalAlpha = 0.7 * this.editor_alpha;
1180
+
1181
+ for (var i = 0; i < groups.length; ++i) {
1182
+ var group = groups[i];
1183
+
1184
+ if (!LiteGraph.overlapBounding(this.visible_area, group._bounding)) {
1185
+ continue;
1186
+ } //out of the visible area
1187
+
1188
+ ctx.fillStyle = group.color || "#335";
1189
+ ctx.strokeStyle = group.color || "#335";
1190
+ var pos = group._pos;
1191
+ var size = group._size;
1192
+ ctx.globalAlpha = 0.25 * this.editor_alpha;
1193
+ ctx.beginPath();
1194
+ var font_size =
1195
+ group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE;
1196
+ ctx.rect(pos[0] + 0.5, pos[1] + 0.5, size[0], font_size * 1.4);
1197
+ ctx.fill();
1198
+ ctx.globalAlpha = this.editor_alpha;
1199
+ }
1200
+
1201
+ ctx.restore();
1202
+
1203
+ const res = origDrawGroups.apply(this, arguments);
1204
+ return res;
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Draws node highlights (executing, drag drop) and progress bar
1210
+ */
1211
+ #addDrawNodeHandler() {
1212
+ const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape;
1213
+ const self = this;
1214
+
1215
+ LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) {
1216
+ const res = origDrawNodeShape.apply(this, arguments);
1217
+
1218
+ const nodeErrors = self.lastNodeErrors?.[node.id];
1219
+
1220
+ let color = null;
1221
+ let lineWidth = 1;
1222
+ if (node.id === +self.runningNodeId) {
1223
+ color = "#0f0";
1224
+ } else if (self.dragOverNode && node.id === self.dragOverNode.id) {
1225
+ color = "dodgerblue";
1226
+ }
1227
+ else if (nodeErrors?.errors) {
1228
+ color = "red";
1229
+ lineWidth = 2;
1230
+ }
1231
+ else if (self.lastExecutionError && +self.lastExecutionError.node_id === node.id) {
1232
+ color = "#f0f";
1233
+ lineWidth = 2;
1234
+ }
1235
+
1236
+ if (color) {
1237
+ const shape = node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE;
1238
+ ctx.lineWidth = lineWidth;
1239
+ ctx.globalAlpha = 0.8;
1240
+ ctx.beginPath();
1241
+ if (shape == LiteGraph.BOX_SHAPE)
1242
+ ctx.rect(-6, -6 - LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT);
1243
+ else if (shape == LiteGraph.ROUND_SHAPE || (shape == LiteGraph.CARD_SHAPE && node.flags.collapsed))
1244
+ ctx.roundRect(
1245
+ -6,
1246
+ -6 - LiteGraph.NODE_TITLE_HEIGHT,
1247
+ 12 + size[0] + 1,
1248
+ 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
1249
+ this.round_radius * 2
1250
+ );
1251
+ else if (shape == LiteGraph.CARD_SHAPE)
1252
+ ctx.roundRect(
1253
+ -6,
1254
+ -6 - LiteGraph.NODE_TITLE_HEIGHT,
1255
+ 12 + size[0] + 1,
1256
+ 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
1257
+ [this.round_radius * 2, this.round_radius * 2, 2, 2]
1258
+ );
1259
+ else if (shape == LiteGraph.CIRCLE_SHAPE)
1260
+ ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2);
1261
+ ctx.strokeStyle = color;
1262
+ ctx.stroke();
1263
+ ctx.strokeStyle = fgcolor;
1264
+ ctx.globalAlpha = 1;
1265
+ }
1266
+
1267
+ if (self.progress && node.id === +self.runningNodeId) {
1268
+ ctx.fillStyle = "green";
1269
+ ctx.fillRect(0, 0, size[0] * (self.progress.value / self.progress.max), 6);
1270
+ ctx.fillStyle = bgcolor;
1271
+ }
1272
+
1273
+ // Highlight inputs that failed validation
1274
+ if (nodeErrors) {
1275
+ ctx.lineWidth = 2;
1276
+ ctx.strokeStyle = "red";
1277
+ for (const error of nodeErrors.errors) {
1278
+ if (error.extra_info && error.extra_info.input_name) {
1279
+ const inputIndex = node.findInputSlot(error.extra_info.input_name)
1280
+ if (inputIndex !== -1) {
1281
+ let pos = node.getConnectionPos(true, inputIndex);
1282
+ ctx.beginPath();
1283
+ ctx.arc(pos[0] - node.pos[0], pos[1] - node.pos[1], 12, 0, 2 * Math.PI, false)
1284
+ ctx.stroke();
1285
+ }
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ return res;
1291
+ };
1292
+
1293
+ const origDrawNode = LGraphCanvas.prototype.drawNode;
1294
+ LGraphCanvas.prototype.drawNode = function (node, ctx) {
1295
+ var editor_alpha = this.editor_alpha;
1296
+ var old_color = node.bgcolor;
1297
+
1298
+ if (node.mode === 2) { // never
1299
+ this.editor_alpha = 0.4;
1300
+ }
1301
+
1302
+ if (node.mode === 4) { // never
1303
+ node.bgcolor = "#FF00FF";
1304
+ this.editor_alpha = 0.2;
1305
+ }
1306
+
1307
+ const res = origDrawNode.apply(this, arguments);
1308
+
1309
+ this.editor_alpha = editor_alpha;
1310
+ node.bgcolor = old_color;
1311
+
1312
+ return res;
1313
+ };
1314
+ }
1315
+
1316
+ /**
1317
+ * Handles updates from the API socket
1318
+ */
1319
+ #addApiUpdateHandlers() {
1320
+ api.addEventListener("status", ({ detail }) => {
1321
+ this.ui.setStatus(detail);
1322
+ });
1323
+
1324
+ api.addEventListener("reconnecting", () => {
1325
+ this.ui.dialog.show("Reconnecting...");
1326
+ });
1327
+
1328
+ api.addEventListener("reconnected", () => {
1329
+ this.ui.dialog.close();
1330
+ });
1331
+
1332
+ api.addEventListener("progress", ({ detail }) => {
1333
+ if (this.workflowManager.activePrompt?.workflow
1334
+ && this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
1335
+ this.progress = detail;
1336
+ this.graph.setDirtyCanvas(true, false);
1337
+ });
1338
+
1339
+ api.addEventListener("executing", ({ detail }) => {
1340
+ if (this.workflowManager.activePrompt ?.workflow
1341
+ && this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
1342
+ this.progress = null;
1343
+ this.runningNodeId = detail;
1344
+ this.graph.setDirtyCanvas(true, false);
1345
+ delete this.nodePreviewImages[this.runningNodeId]
1346
+ });
1347
+
1348
+ api.addEventListener("executed", ({ detail }) => {
1349
+ if (this.workflowManager.activePrompt ?.workflow
1350
+ && this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
1351
+ const output = this.nodeOutputs[detail.node];
1352
+ if (detail.merge && output) {
1353
+ for (const k in detail.output ?? {}) {
1354
+ const v = output[k];
1355
+ if (v instanceof Array) {
1356
+ output[k] = v.concat(detail.output[k]);
1357
+ } else {
1358
+ output[k] = detail.output[k];
1359
+ }
1360
+ }
1361
+ } else {
1362
+ this.nodeOutputs[detail.node] = detail.output;
1363
+ }
1364
+ const node = this.graph.getNodeById(detail.node);
1365
+ if (node) {
1366
+ if (node.onExecuted)
1367
+ node.onExecuted(detail.output);
1368
+ }
1369
+ });
1370
+
1371
+ api.addEventListener("execution_start", ({ detail }) => {
1372
+ this.runningNodeId = null;
1373
+ this.lastExecutionError = null
1374
+ this.graph._nodes.forEach((node) => {
1375
+ if (node.onExecutionStart)
1376
+ node.onExecutionStart()
1377
+ })
1378
+ });
1379
+
1380
+ api.addEventListener("execution_error", ({ detail }) => {
1381
+ this.lastExecutionError = detail;
1382
+ const formattedError = this.#formatExecutionError(detail);
1383
+ this.ui.dialog.show(formattedError);
1384
+ this.canvas.draw(true, true);
1385
+ });
1386
+
1387
+ api.addEventListener("b_preview", ({ detail }) => {
1388
+ const id = this.runningNodeId
1389
+ if (id == null)
1390
+ return;
1391
+
1392
+ const blob = detail
1393
+ const blobUrl = URL.createObjectURL(blob)
1394
+ this.nodePreviewImages[id] = [blobUrl]
1395
+ });
1396
+
1397
+ api.init();
1398
+ }
1399
+
1400
+ #addKeyboardHandler() {
1401
+ window.addEventListener("keydown", (e) => {
1402
+ this.shiftDown = e.shiftKey;
1403
+ });
1404
+ window.addEventListener("keyup", (e) => {
1405
+ this.shiftDown = e.shiftKey;
1406
+ });
1407
+ }
1408
+
1409
+ #addConfigureHandler() {
1410
+ const app = this;
1411
+ const configure = LGraph.prototype.configure;
1412
+ // Flag that the graph is configuring to prevent nodes from running checks while its still loading
1413
+ LGraph.prototype.configure = function () {
1414
+ app.configuringGraph = true;
1415
+ try {
1416
+ return configure.apply(this, arguments);
1417
+ } finally {
1418
+ app.configuringGraph = false;
1419
+ }
1420
+ };
1421
+ }
1422
+
1423
+ #addAfterConfigureHandler() {
1424
+ const app = this;
1425
+ const onConfigure = app.graph.onConfigure;
1426
+ app.graph.onConfigure = function () {
1427
+ // Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
1428
+ for (const node of app.graph._nodes) {
1429
+ node.onGraphConfigured?.();
1430
+ }
1431
+
1432
+ const r = onConfigure?.apply(this, arguments);
1433
+
1434
+ // Fire after onConfigure, used by primitves to generate widget using input nodes config
1435
+ for (const node of app.graph._nodes) {
1436
+ node.onAfterGraphConfigured?.();
1437
+ }
1438
+
1439
+ return r;
1440
+ };
1441
+ }
1442
+
1443
+ /**
1444
+ * Loads all extensions from the API into the window in parallel
1445
+ */
1446
+ async #loadExtensions() {
1447
+ const extensions = await api.getExtensions();
1448
+ this.logging.addEntry("Comfy.App", "debug", { Extensions: extensions });
1449
+
1450
+ const extensionPromises = extensions.map(async ext => {
1451
+ try {
1452
+ await import(api.apiURL(ext));
1453
+ } catch (error) {
1454
+ console.error("Error loading extension", ext, error);
1455
+ }
1456
+ });
1457
+
1458
+ await Promise.all(extensionPromises);
1459
+ try {
1460
+ this.menu.workflows.registerExtension(this);
1461
+ } catch (error) {
1462
+ console.error(error);
1463
+ }
1464
+ }
1465
+
1466
+ async #migrateSettings() {
1467
+ this.isNewUserSession = true;
1468
+ // Store all current settings
1469
+ const settings = Object.keys(this.ui.settings).reduce((p, n) => {
1470
+ const v = localStorage[`Comfy.Settings.${n}`];
1471
+ if (v) {
1472
+ try {
1473
+ p[n] = JSON.parse(v);
1474
+ } catch (error) {}
1475
+ }
1476
+ return p;
1477
+ }, {});
1478
+
1479
+ await api.storeSettings(settings);
1480
+ }
1481
+
1482
+ async #setUser() {
1483
+ const userConfig = await api.getUserConfig();
1484
+ this.storageLocation = userConfig.storage;
1485
+ if (typeof userConfig.migrated == "boolean") {
1486
+ // Single user mode migrated true/false for if the default user is created
1487
+ if (!userConfig.migrated && this.storageLocation === "server") {
1488
+ // Default user not created yet
1489
+ await this.#migrateSettings();
1490
+ }
1491
+ return;
1492
+ }
1493
+
1494
+ this.multiUserServer = true;
1495
+ let user = localStorage["Comfy.userId"];
1496
+ const users = userConfig.users ?? {};
1497
+ if (!user || !users[user]) {
1498
+ // This will rarely be hit so move the loading to on demand
1499
+ const { UserSelectionScreen } = await import("./ui/userSelection.js");
1500
+
1501
+ this.ui.menuContainer.style.display = "none";
1502
+ const { userId, username, created } = await new UserSelectionScreen().show(users, user);
1503
+ this.ui.menuContainer.style.display = "";
1504
+
1505
+ user = userId;
1506
+ localStorage["Comfy.userName"] = username;
1507
+ localStorage["Comfy.userId"] = user;
1508
+
1509
+ if (created) {
1510
+ api.user = user;
1511
+ await this.#migrateSettings();
1512
+ }
1513
+ }
1514
+
1515
+ api.user = user;
1516
+
1517
+ this.ui.settings.addSetting({
1518
+ id: "Comfy.SwitchUser",
1519
+ name: "Switch User",
1520
+ type: (name) => {
1521
+ let currentUser = localStorage["Comfy.userName"];
1522
+ if (currentUser) {
1523
+ currentUser = ` (${currentUser})`;
1524
+ }
1525
+ return $el("tr", [
1526
+ $el("td", [
1527
+ $el("label", {
1528
+ textContent: name,
1529
+ }),
1530
+ ]),
1531
+ $el("td", [
1532
+ $el("button", {
1533
+ textContent: name + (currentUser ?? ""),
1534
+ onclick: () => {
1535
+ delete localStorage["Comfy.userId"];
1536
+ delete localStorage["Comfy.userName"];
1537
+ window.location.reload();
1538
+ },
1539
+ }),
1540
+ ]),
1541
+ ]);
1542
+ },
1543
+ });
1544
+ }
1545
+
1546
+ /**
1547
+ * Set up the app on the page
1548
+ */
1549
+ async setup() {
1550
+ await this.#setUser();
1551
+
1552
+ // Create and mount the LiteGraph in the DOM
1553
+ const mainCanvas = document.createElement("canvas")
1554
+ mainCanvas.style.touchAction = "none"
1555
+ const canvasEl = (this.canvasEl = Object.assign(mainCanvas, { id: "graph-canvas" }));
1556
+ canvasEl.tabIndex = "1";
1557
+ document.body.append(canvasEl);
1558
+ this.resizeCanvas();
1559
+
1560
+ await Promise.all([this.workflowManager.loadWorkflows(), this.ui.settings.load()]);
1561
+ await this.#loadExtensions();
1562
+
1563
+ addDomClippingSetting();
1564
+ this.#addProcessMouseHandler();
1565
+ this.#addProcessKeyHandler();
1566
+ this.#addConfigureHandler();
1567
+ this.#addApiUpdateHandlers();
1568
+ this.#addRestoreWorkflowView();
1569
+
1570
+ this.graph = new LGraph();
1571
+
1572
+ this.#addAfterConfigureHandler();
1573
+
1574
+ this.canvas = new LGraphCanvas(canvasEl, this.graph);
1575
+ this.ctx = canvasEl.getContext("2d");
1576
+
1577
+ LiteGraph.release_link_on_empty_shows_menu = true;
1578
+ LiteGraph.alt_drag_do_clone_nodes = true;
1579
+
1580
+ this.graph.start();
1581
+
1582
+ // Ensure the canvas fills the window
1583
+ this.resizeCanvas();
1584
+ window.addEventListener("resize", () => this.resizeCanvas());
1585
+ const ro = new ResizeObserver(() => this.resizeCanvas());
1586
+ ro.observe(this.bodyTop);
1587
+ ro.observe(this.bodyLeft);
1588
+ ro.observe(this.bodyRight);
1589
+ ro.observe(this.bodyBottom);
1590
+
1591
+ await this.#invokeExtensionsAsync("init");
1592
+ await this.registerNodes();
1593
+ initWidgets(this);
1594
+
1595
+ // Load previous workflow
1596
+ let restored = false;
1597
+ try {
1598
+ const loadWorkflow = async (json) => {
1599
+ if (json) {
1600
+ const workflow = JSON.parse(json);
1601
+ const workflowName = getStorageValue("Comfy.PreviousWorkflow");
1602
+ await this.loadGraphData(workflow, true, true, workflowName);
1603
+ return true;
1604
+ }
1605
+ };
1606
+ const clientId = api.initialClientId ?? api.clientId;
1607
+ restored =
1608
+ (clientId && (await loadWorkflow(sessionStorage.getItem(`workflow:${clientId}`)))) ||
1609
+ (await loadWorkflow(localStorage.getItem("workflow")));
1610
+ } catch (err) {
1611
+ console.error("Error loading previous workflow", err);
1612
+ }
1613
+
1614
+ // We failed to restore a workflow so load the default
1615
+ if (!restored) {
1616
+ await this.loadGraphData();
1617
+ }
1618
+
1619
+ // Save current workflow automatically
1620
+ setInterval(() => {
1621
+ const workflow = JSON.stringify(this.graph.serialize());
1622
+ localStorage.setItem("workflow", workflow);
1623
+ if (api.clientId) {
1624
+ sessionStorage.setItem(`workflow:${api.clientId}`, workflow);
1625
+ }
1626
+ }, 1000);
1627
+
1628
+ this.#addDrawNodeHandler();
1629
+ this.#addDrawGroupsHandler();
1630
+ this.#addDropHandler();
1631
+ this.#addCopyHandler();
1632
+ this.#addPasteHandler();
1633
+ this.#addKeyboardHandler();
1634
+
1635
+ await this.#invokeExtensionsAsync("setup");
1636
+ }
1637
+
1638
+ resizeCanvas() {
1639
+ // Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
1640
+ const scale = Math.max(window.devicePixelRatio, 1);
1641
+
1642
+ // Clear fixed width and height while calculating rect so it uses 100% instead
1643
+ this.canvasEl.height = this.canvasEl.width = "";
1644
+ const { width, height } = this.canvasEl.getBoundingClientRect();
1645
+ this.canvasEl.width = Math.round(width * scale);
1646
+ this.canvasEl.height = Math.round(height * scale);
1647
+ this.canvasEl.getContext("2d").scale(scale, scale);
1648
+ this.canvas?.draw(true, true);
1649
+ }
1650
+
1651
+ /**
1652
+ * Registers nodes with the graph
1653
+ */
1654
+ async registerNodes() {
1655
+ const app = this;
1656
+ // Load node definitions from the backend
1657
+ const defs = await api.getNodeDefs();
1658
+ await this.registerNodesFromDefs(defs);
1659
+ await this.#invokeExtensionsAsync("registerCustomNodes");
1660
+ }
1661
+
1662
+ getWidgetType(inputData, inputName) {
1663
+ const type = inputData[0];
1664
+
1665
+ if (Array.isArray(type)) {
1666
+ return "COMBO";
1667
+ } else if (`${type}:${inputName}` in this.widgets) {
1668
+ return `${type}:${inputName}`;
1669
+ } else if (type in this.widgets) {
1670
+ return type;
1671
+ } else {
1672
+ return null;
1673
+ }
1674
+ }
1675
+
1676
+ async registerNodeDef(nodeId, nodeData) {
1677
+ const self = this;
1678
+ const node = Object.assign(
1679
+ function ComfyNode() {
1680
+ var inputs = nodeData["input"]["required"];
1681
+ if (nodeData["input"]["optional"] != undefined) {
1682
+ inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"]);
1683
+ }
1684
+ const config = { minWidth: 1, minHeight: 1 };
1685
+ for (const inputName in inputs) {
1686
+ const inputData = inputs[inputName];
1687
+ const type = inputData[0];
1688
+
1689
+ let widgetCreated = true;
1690
+ const widgetType = self.getWidgetType(inputData, inputName);
1691
+ if(widgetType) {
1692
+ if(widgetType === "COMBO") {
1693
+ Object.assign(config, self.widgets.COMBO(this, inputName, inputData, app) || {});
1694
+ } else {
1695
+ Object.assign(config, self.widgets[widgetType](this, inputName, inputData, app) || {});
1696
+ }
1697
+ } else {
1698
+ // Node connection inputs
1699
+ this.addInput(inputName, type);
1700
+ widgetCreated = false;
1701
+ }
1702
+
1703
+ if(widgetCreated && inputData[1]?.forceInput && config?.widget) {
1704
+ if (!config.widget.options) config.widget.options = {};
1705
+ config.widget.options.forceInput = inputData[1].forceInput;
1706
+ }
1707
+ if(widgetCreated && inputData[1]?.defaultInput && config?.widget) {
1708
+ if (!config.widget.options) config.widget.options = {};
1709
+ config.widget.options.defaultInput = inputData[1].defaultInput;
1710
+ }
1711
+ }
1712
+
1713
+ for (const o in nodeData["output"]) {
1714
+ let output = nodeData["output"][o];
1715
+ if(output instanceof Array) output = "COMBO";
1716
+ const outputName = nodeData["output_name"][o] || output;
1717
+ const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE ;
1718
+ this.addOutput(outputName, output, { shape: outputShape });
1719
+ }
1720
+
1721
+ const s = this.computeSize();
1722
+ s[0] = Math.max(config.minWidth, s[0] * 1.5);
1723
+ s[1] = Math.max(config.minHeight, s[1]);
1724
+ this.size = s;
1725
+ this.serialize_widgets = true;
1726
+
1727
+ app.#invokeExtensionsAsync("nodeCreated", this);
1728
+ },
1729
+ {
1730
+ title: nodeData.display_name || nodeData.name,
1731
+ comfyClass: nodeData.name,
1732
+ nodeData
1733
+ }
1734
+ );
1735
+ node.prototype.comfyClass = nodeData.name;
1736
+
1737
+ this.#addNodeContextMenuHandler(node);
1738
+ this.#addDrawBackgroundHandler(node, app);
1739
+ this.#addNodeKeyHandler(node);
1740
+
1741
+ await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
1742
+ LiteGraph.registerNodeType(nodeId, node);
1743
+ node.category = nodeData.category;
1744
+ }
1745
+
1746
+ async registerNodesFromDefs(defs) {
1747
+ await this.#invokeExtensionsAsync("addCustomNodeDefs", defs);
1748
+
1749
+ // Generate list of known widgets
1750
+ this.widgets = Object.assign(
1751
+ {},
1752
+ ComfyWidgets,
1753
+ ...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean)
1754
+ );
1755
+
1756
+ // Register a node for each definition
1757
+ for (const nodeId in defs) {
1758
+ this.registerNodeDef(nodeId, defs[nodeId]);
1759
+ }
1760
+ }
1761
+
1762
+ loadTemplateData(templateData) {
1763
+ if (!templateData?.templates) {
1764
+ return;
1765
+ }
1766
+
1767
+ const old = localStorage.getItem("litegrapheditor_clipboard");
1768
+
1769
+ var maxY, nodeBottom, node;
1770
+
1771
+ for (const template of templateData.templates) {
1772
+ if (!template?.data) {
1773
+ continue;
1774
+ }
1775
+
1776
+ localStorage.setItem("litegrapheditor_clipboard", template.data);
1777
+ app.canvas.pasteFromClipboard();
1778
+
1779
+ // Move mouse position down to paste the next template below
1780
+
1781
+ maxY = false;
1782
+
1783
+ for (const i in app.canvas.selected_nodes) {
1784
+ node = app.canvas.selected_nodes[i];
1785
+
1786
+ nodeBottom = node.pos[1] + node.size[1];
1787
+
1788
+ if (maxY === false || nodeBottom > maxY) {
1789
+ maxY = nodeBottom;
1790
+ }
1791
+ }
1792
+
1793
+ app.canvas.graph_mouse[1] = maxY + 50;
1794
+ }
1795
+
1796
+ localStorage.setItem("litegrapheditor_clipboard", old);
1797
+ }
1798
+
1799
+ showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
1800
+ let seenTypes = new Set();
1801
+
1802
+ this.ui.dialog.show(
1803
+ $el("div.comfy-missing-nodes", [
1804
+ $el("span", { textContent: "When loading the graph, the following node types were not found: " }),
1805
+ $el(
1806
+ "ul",
1807
+ Array.from(new Set(missingNodeTypes)).map((t) => {
1808
+ let children = [];
1809
+ if (typeof t === "object") {
1810
+ if(seenTypes.has(t.type)) return null;
1811
+ seenTypes.add(t.type);
1812
+ children.push($el("span", { textContent: t.type }));
1813
+ if (t.hint) {
1814
+ children.push($el("span", { textContent: t.hint }));
1815
+ }
1816
+ if (t.action) {
1817
+ children.push($el("button", { onclick: t.action.callback, textContent: t.action.text }));
1818
+ }
1819
+ } else {
1820
+ if(seenTypes.has(t)) return null;
1821
+ seenTypes.add(t);
1822
+ children.push($el("span", { textContent: t }));
1823
+ }
1824
+ return $el("li", children);
1825
+ }).filter(Boolean)
1826
+ ),
1827
+ ...(hasAddedNodes
1828
+ ? [$el("span", { textContent: "Nodes that have failed to load will show as red on the graph." })]
1829
+ : []),
1830
+ ])
1831
+ );
1832
+ this.logging.addEntry("Comfy.App", "warn", {
1833
+ MissingNodes: missingNodeTypes,
1834
+ });
1835
+ }
1836
+
1837
+ async changeWorkflow(callback, workflow = null) {
1838
+ try {
1839
+ this.workflowManager.activeWorkflow?.changeTracker?.store()
1840
+ } catch (error) {
1841
+ console.error(error);
1842
+ }
1843
+ await callback();
1844
+ try {
1845
+ this.workflowManager.setWorkflow(workflow);
1846
+ this.workflowManager.activeWorkflow?.track()
1847
+ } catch (error) {
1848
+ console.error(error);
1849
+ }
1850
+ }
1851
+
1852
+ /**
1853
+ * Populates the graph with the specified workflow data
1854
+ * @param {*} graphData A serialized graph object
1855
+ * @param { boolean } clean If the graph state, e.g. images, should be cleared
1856
+ * @param { boolean } restore_view If the graph position should be restored
1857
+ * @param { import("./workflows.js").ComfyWorkflowInstance | null } workflow The workflow
1858
+ */
1859
+ async loadGraphData(graphData, clean = true, restore_view = true, workflow = null) {
1860
+ if (clean !== false) {
1861
+ this.clean();
1862
+ }
1863
+
1864
+ let reset_invalid_values = false;
1865
+ if (!graphData) {
1866
+ graphData = defaultGraph;
1867
+ reset_invalid_values = true;
1868
+ }
1869
+
1870
+ if (typeof structuredClone === "undefined")
1871
+ {
1872
+ graphData = JSON.parse(JSON.stringify(graphData));
1873
+ }else
1874
+ {
1875
+ graphData = structuredClone(graphData);
1876
+ }
1877
+
1878
+ try {
1879
+ this.workflowManager.setWorkflow(workflow);
1880
+ } catch (error) {
1881
+ console.error(error);
1882
+ }
1883
+
1884
+ const missingNodeTypes = [];
1885
+ await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes);
1886
+ for (let n of graphData.nodes) {
1887
+ // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
1888
+ if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
1889
+ if (n.type == "ConditioningAverage ") n.type = "ConditioningAverage"; //typo fix
1890
+ if (n.type == "SDV_img2vid_Conditioning") n.type = "SVD_img2vid_Conditioning"; //typo fix
1891
+
1892
+ // Find missing node types
1893
+ if (!(n.type in LiteGraph.registered_node_types)) {
1894
+ missingNodeTypes.push(n.type);
1895
+ n.type = sanitizeNodeName(n.type);
1896
+ }
1897
+ }
1898
+
1899
+ try {
1900
+ this.graph.configure(graphData);
1901
+ if (restore_view && this.enableWorkflowViewRestore.value && graphData.extra?.ds) {
1902
+ this.canvas.ds.offset = graphData.extra.ds.offset;
1903
+ this.canvas.ds.scale = graphData.extra.ds.scale;
1904
+ }
1905
+
1906
+ try {
1907
+ this.workflowManager.activeWorkflow?.track()
1908
+ } catch (error) {
1909
+ }
1910
+ } catch (error) {
1911
+ let errorHint = [];
1912
+ // Try extracting filename to see if it was caused by an extension script
1913
+ const filename = error.fileName || (error.stack || "").match(/(\/extensions\/.*\.js)/)?.[1];
1914
+ const pos = (filename || "").indexOf("/extensions/");
1915
+ if (pos > -1) {
1916
+ errorHint.push(
1917
+ $el("span", { textContent: "This may be due to the following script:" }),
1918
+ $el("br"),
1919
+ $el("span", {
1920
+ style: {
1921
+ fontWeight: "bold",
1922
+ },
1923
+ textContent: filename.substring(pos),
1924
+ })
1925
+ );
1926
+ }
1927
+
1928
+ // Show dialog to let the user know something went wrong loading the data
1929
+ this.ui.dialog.show(
1930
+ $el("div", [
1931
+ $el("p", { textContent: "Loading aborted due to error reloading workflow data" }),
1932
+ $el("pre", {
1933
+ style: { padding: "5px", backgroundColor: "rgba(255,0,0,0.2)" },
1934
+ textContent: error.toString(),
1935
+ }),
1936
+ $el("pre", {
1937
+ style: {
1938
+ padding: "5px",
1939
+ color: "#ccc",
1940
+ fontSize: "10px",
1941
+ maxHeight: "50vh",
1942
+ overflow: "auto",
1943
+ backgroundColor: "rgba(0,0,0,0.2)",
1944
+ },
1945
+ textContent: error.stack || "No stacktrace available",
1946
+ }),
1947
+ ...errorHint,
1948
+ ]).outerHTML
1949
+ );
1950
+
1951
+ return;
1952
+ }
1953
+
1954
+ for (const node of this.graph._nodes) {
1955
+ const size = node.computeSize();
1956
+ size[0] = Math.max(node.size[0], size[0]);
1957
+ size[1] = Math.max(node.size[1], size[1]);
1958
+ node.size = size;
1959
+
1960
+ if (node.widgets) {
1961
+ // If you break something in the backend and want to patch workflows in the frontend
1962
+ // This is the place to do this
1963
+ for (let widget of node.widgets) {
1964
+ if (node.type == "KSampler" || node.type == "KSamplerAdvanced") {
1965
+ if (widget.name == "sampler_name") {
1966
+ if (widget.value.startsWith("sample_")) {
1967
+ widget.value = widget.value.slice(7);
1968
+ }
1969
+ if (widget.value === "euler_pp" || widget.value === "euler_ancestral_pp") {
1970
+ widget.value = widget.value.slice(0, -3);
1971
+ for (let w of node.widgets) {
1972
+ if (w.name == "cfg") {
1973
+ w.value *= 2.0;
1974
+ }
1975
+ }
1976
+ }
1977
+ }
1978
+ }
1979
+ if (node.type == "KSampler" || node.type == "KSamplerAdvanced" || node.type == "PrimitiveNode") {
1980
+ if (widget.name == "control_after_generate") {
1981
+ if (widget.value === true) {
1982
+ widget.value = "randomize";
1983
+ } else if (widget.value === false) {
1984
+ widget.value = "fixed";
1985
+ }
1986
+ }
1987
+ }
1988
+ if (reset_invalid_values) {
1989
+ if (widget.type == "combo") {
1990
+ if (!widget.options.values.includes(widget.value) && widget.options.values.length > 0) {
1991
+ widget.value = widget.options.values[0];
1992
+ }
1993
+ }
1994
+ }
1995
+ }
1996
+ }
1997
+
1998
+ this.#invokeExtensions("loadedGraphNode", node);
1999
+ }
2000
+
2001
+ if (missingNodeTypes.length) {
2002
+ this.showMissingNodesError(missingNodeTypes);
2003
+ }
2004
+ await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
2005
+ requestAnimationFrame(() => {
2006
+ this.graph.setDirtyCanvas(true, true);
2007
+ });
2008
+ }
2009
+
2010
+ /**
2011
+ * Converts the current graph workflow for sending to the API
2012
+ * @returns The workflow and node links
2013
+ */
2014
+ async graphToPrompt(graph = this.graph, clean = true) {
2015
+ for (const outerNode of graph.computeExecutionOrder(false)) {
2016
+ if (outerNode.widgets) {
2017
+ for (const widget of outerNode.widgets) {
2018
+ // Allow widgets to run callbacks before a prompt has been queued
2019
+ // e.g. random seed before every gen
2020
+ widget.beforeQueued?.();
2021
+ }
2022
+ }
2023
+
2024
+ const innerNodes = outerNode.getInnerNodes ? outerNode.getInnerNodes() : [outerNode];
2025
+ for (const node of innerNodes) {
2026
+ if (node.isVirtualNode) {
2027
+ // Don't serialize frontend only nodes but let them make changes
2028
+ if (node.applyToGraph) {
2029
+ node.applyToGraph();
2030
+ }
2031
+ }
2032
+ }
2033
+ }
2034
+
2035
+ const workflow = graph.serialize();
2036
+ const output = {};
2037
+ // Process nodes in order of execution
2038
+ for (const outerNode of graph.computeExecutionOrder(false)) {
2039
+ const skipNode = outerNode.mode === 2 || outerNode.mode === 4;
2040
+ const innerNodes = (!skipNode && outerNode.getInnerNodes) ? outerNode.getInnerNodes() : [outerNode];
2041
+ for (const node of innerNodes) {
2042
+ if (node.isVirtualNode) {
2043
+ continue;
2044
+ }
2045
+
2046
+ if (node.mode === 2 || node.mode === 4) {
2047
+ // Don't serialize muted nodes
2048
+ continue;
2049
+ }
2050
+
2051
+ const inputs = {};
2052
+ const widgets = node.widgets;
2053
+
2054
+ // Store all widget values
2055
+ if (widgets) {
2056
+ for (const i in widgets) {
2057
+ const widget = widgets[i];
2058
+ if (!widget.options || widget.options.serialize !== false) {
2059
+ inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(node, i) : widget.value;
2060
+ }
2061
+ }
2062
+ }
2063
+
2064
+ // Store all node links
2065
+ for (let i in node.inputs) {
2066
+ let parent = node.getInputNode(i);
2067
+ if (parent) {
2068
+ let link = node.getInputLink(i);
2069
+ while (parent.mode === 4 || parent.isVirtualNode) {
2070
+ let found = false;
2071
+ if (parent.isVirtualNode) {
2072
+ link = parent.getInputLink(link.origin_slot);
2073
+ if (link) {
2074
+ parent = parent.getInputNode(link.target_slot);
2075
+ if (parent) {
2076
+ found = true;
2077
+ }
2078
+ }
2079
+ } else if (link && parent.mode === 4) {
2080
+ let all_inputs = [link.origin_slot];
2081
+ if (parent.inputs) {
2082
+ all_inputs = all_inputs.concat(Object.keys(parent.inputs))
2083
+ for (let parent_input in all_inputs) {
2084
+ parent_input = all_inputs[parent_input];
2085
+ if (parent.inputs[parent_input]?.type === node.inputs[i].type) {
2086
+ link = parent.getInputLink(parent_input);
2087
+ if (link) {
2088
+ parent = parent.getInputNode(parent_input);
2089
+ }
2090
+ found = true;
2091
+ break;
2092
+ }
2093
+ }
2094
+ }
2095
+ }
2096
+
2097
+ if (!found) {
2098
+ break;
2099
+ }
2100
+ }
2101
+
2102
+ if (link) {
2103
+ if (parent?.updateLink) {
2104
+ link = parent.updateLink(link);
2105
+ }
2106
+ if (link) {
2107
+ inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)];
2108
+ }
2109
+ }
2110
+ }
2111
+ }
2112
+
2113
+ let node_data = {
2114
+ inputs,
2115
+ class_type: node.comfyClass,
2116
+ };
2117
+
2118
+ if (this.ui.settings.getSettingValue("Comfy.DevMode")) {
2119
+ // Ignored by the backend.
2120
+ node_data["_meta"] = {
2121
+ title: node.title,
2122
+ }
2123
+ }
2124
+
2125
+ output[String(node.id)] = node_data;
2126
+ }
2127
+ }
2128
+
2129
+ // Remove inputs connected to removed nodes
2130
+ if(clean) {
2131
+ for (const o in output) {
2132
+ for (const i in output[o].inputs) {
2133
+ if (Array.isArray(output[o].inputs[i])
2134
+ && output[o].inputs[i].length === 2
2135
+ && !output[output[o].inputs[i][0]]) {
2136
+ delete output[o].inputs[i];
2137
+ }
2138
+ }
2139
+ }
2140
+ }
2141
+
2142
+ return { workflow, output };
2143
+ }
2144
+
2145
+ #formatPromptError(error) {
2146
+ if (error == null) {
2147
+ return "(unknown error)"
2148
+ }
2149
+ else if (typeof error === "string") {
2150
+ return error;
2151
+ }
2152
+ else if (error.stack && error.message) {
2153
+ return error.toString()
2154
+ }
2155
+ else if (error.response) {
2156
+ let message = error.response.error.message;
2157
+ if (error.response.error.details)
2158
+ message += ": " + error.response.error.details;
2159
+ for (const [nodeID, nodeError] of Object.entries(error.response.node_errors)) {
2160
+ message += "\n" + nodeError.class_type + ":"
2161
+ for (const errorReason of nodeError.errors) {
2162
+ message += "\n - " + errorReason.message + ": " + errorReason.details
2163
+ }
2164
+ }
2165
+ return message
2166
+ }
2167
+ return "(unknown error)"
2168
+ }
2169
+
2170
+ #formatExecutionError(error) {
2171
+ if (error == null) {
2172
+ return "(unknown error)"
2173
+ }
2174
+
2175
+ const traceback = error.traceback.join("")
2176
+ const nodeId = error.node_id
2177
+ const nodeType = error.node_type
2178
+
2179
+ return `Error occurred when executing ${nodeType}:\n\n${error.exception_message}\n\n${traceback}`
2180
+ }
2181
+
2182
+ async queuePrompt(number, batchCount = 1) {
2183
+ this.#queueItems.push({ number, batchCount });
2184
+
2185
+ // Only have one action process the items so each one gets a unique seed correctly
2186
+ if (this.#processingQueue) {
2187
+ return;
2188
+ }
2189
+
2190
+ this.#processingQueue = true;
2191
+ this.lastNodeErrors = null;
2192
+
2193
+ try {
2194
+ while (this.#queueItems.length) {
2195
+ ({ number, batchCount } = this.#queueItems.pop());
2196
+
2197
+ for (let i = 0; i < batchCount; i++) {
2198
+ const p = await this.graphToPrompt();
2199
+
2200
+ try {
2201
+ const res = await api.queuePrompt(number, p);
2202
+ this.lastNodeErrors = res.node_errors;
2203
+ if (this.lastNodeErrors.length > 0) {
2204
+ this.canvas.draw(true, true);
2205
+ } else {
2206
+ try {
2207
+ this.workflowManager.storePrompt({
2208
+ id: res.prompt_id,
2209
+ nodes: Object.keys(p.output)
2210
+ });
2211
+ } catch (error) {
2212
+ }
2213
+ }
2214
+ } catch (error) {
2215
+ const formattedError = this.#formatPromptError(error)
2216
+ this.ui.dialog.show(formattedError);
2217
+ if (error.response) {
2218
+ this.lastNodeErrors = error.response.node_errors;
2219
+ this.canvas.draw(true, true);
2220
+ }
2221
+ break;
2222
+ }
2223
+
2224
+ for (const n of p.workflow.nodes) {
2225
+ const node = graph.getNodeById(n.id);
2226
+ if (node.widgets) {
2227
+ for (const widget of node.widgets) {
2228
+ // Allow widgets to run callbacks after a prompt has been queued
2229
+ // e.g. random seed after every gen
2230
+ if (widget.afterQueued) {
2231
+ widget.afterQueued();
2232
+ }
2233
+ }
2234
+ }
2235
+ }
2236
+
2237
+ this.canvas.draw(true, true);
2238
+ await this.ui.queue.update();
2239
+ }
2240
+ }
2241
+ } finally {
2242
+ this.#processingQueue = false;
2243
+ }
2244
+ api.dispatchEvent(new CustomEvent("promptQueued", { detail: { number, batchCount } }));
2245
+ return !this.lastNodeErrors;
2246
+ }
2247
+
2248
+ showErrorOnFileLoad(file) {
2249
+ this.ui.dialog.show(
2250
+ $el("div", [
2251
+ $el("p", {textContent: `Unable to find workflow in ${file.name}`})
2252
+ ]).outerHTML
2253
+ );
2254
+ }
2255
+
2256
+ /**
2257
+ * Loads workflow data from the specified file
2258
+ * @param {File} file
2259
+ */
2260
+ async handleFile(file) {
2261
+ const removeExt = f => {
2262
+ if(!f) return f;
2263
+ const p = f.lastIndexOf(".");
2264
+ if(p === -1) return f;
2265
+ return f.substring(0, p);
2266
+ };
2267
+
2268
+ const fileName = removeExt(file.name);
2269
+ if (file.type === "image/png") {
2270
+ const pngInfo = await getPngMetadata(file);
2271
+ if (pngInfo?.workflow) {
2272
+ await this.loadGraphData(JSON.parse(pngInfo.workflow), true, true, fileName);
2273
+ } else if (pngInfo?.prompt) {
2274
+ this.loadApiJson(JSON.parse(pngInfo.prompt), fileName);
2275
+ } else if (pngInfo?.parameters) {
2276
+ this.changeWorkflow(() => {
2277
+ importA1111(this.graph, pngInfo.parameters);
2278
+ }, fileName)
2279
+ } else {
2280
+ this.showErrorOnFileLoad(file);
2281
+ }
2282
+ } else if (file.type === "image/webp") {
2283
+ const pngInfo = await getWebpMetadata(file);
2284
+ // Support loading workflows from that webp custom node.
2285
+ const workflow = pngInfo?.workflow || pngInfo?.Workflow;
2286
+ const prompt = pngInfo?.prompt || pngInfo?.Prompt;
2287
+
2288
+ if (workflow) {
2289
+ this.loadGraphData(JSON.parse(workflow), true, true, fileName);
2290
+ } else if (prompt) {
2291
+ this.loadApiJson(JSON.parse(prompt), fileName);
2292
+ } else {
2293
+ this.showErrorOnFileLoad(file);
2294
+ }
2295
+ } else if (file.type === "audio/flac" || file.type === "audio/x-flac") {
2296
+ const pngInfo = await getFlacMetadata(file);
2297
+ // Support loading workflows from that webp custom node.
2298
+ const workflow = pngInfo?.workflow;
2299
+ const prompt = pngInfo?.prompt;
2300
+
2301
+ if (workflow) {
2302
+ this.loadGraphData(JSON.parse(workflow), true, true, fileName);
2303
+ } else if (prompt) {
2304
+ this.loadApiJson(JSON.parse(prompt), fileName);
2305
+ } else {
2306
+ this.showErrorOnFileLoad(file);
2307
+ }
2308
+ } else if (file.type === "application/json" || file.name?.endsWith(".json")) {
2309
+ const reader = new FileReader();
2310
+ reader.onload = async () => {
2311
+ const jsonContent = JSON.parse(reader.result);
2312
+ if (jsonContent?.templates) {
2313
+ this.loadTemplateData(jsonContent);
2314
+ } else if(this.isApiJson(jsonContent)) {
2315
+ this.loadApiJson(jsonContent, fileName);
2316
+ } else {
2317
+ await this.loadGraphData(jsonContent, true, true, fileName);
2318
+ }
2319
+ };
2320
+ reader.readAsText(file);
2321
+ } else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
2322
+ const info = await getLatentMetadata(file);
2323
+ if (info.workflow) {
2324
+ await this.loadGraphData(JSON.parse(info.workflow), true, true, fileName);
2325
+ } else if (info.prompt) {
2326
+ this.loadApiJson(JSON.parse(info.prompt));
2327
+ } else {
2328
+ this.showErrorOnFileLoad(file);
2329
+ }
2330
+ } else {
2331
+ this.showErrorOnFileLoad(file);
2332
+ }
2333
+ }
2334
+
2335
+ isApiJson(data) {
2336
+ return Object.values(data).every((v) => v.class_type);
2337
+ }
2338
+
2339
+ loadApiJson(apiData, fileName) {
2340
+ const missingNodeTypes = Object.values(apiData).filter((n) => !LiteGraph.registered_node_types[n.class_type]);
2341
+ if (missingNodeTypes.length) {
2342
+ this.showMissingNodesError(missingNodeTypes.map(t => t.class_type), false);
2343
+ return;
2344
+ }
2345
+
2346
+ const ids = Object.keys(apiData);
2347
+ app.graph.clear();
2348
+ for (const id of ids) {
2349
+ const data = apiData[id];
2350
+ const node = LiteGraph.createNode(data.class_type);
2351
+ node.id = isNaN(+id) ? id : +id;
2352
+ node.title = data._meta?.title ?? node.title
2353
+ app.graph.add(node);
2354
+ }
2355
+
2356
+ this.changeWorkflow(() => {
2357
+ for (const id of ids) {
2358
+ const data = apiData[id];
2359
+ const node = app.graph.getNodeById(id);
2360
+ for (const input in data.inputs ?? {}) {
2361
+ const value = data.inputs[input];
2362
+ if (value instanceof Array) {
2363
+ const [fromId, fromSlot] = value;
2364
+ const fromNode = app.graph.getNodeById(fromId);
2365
+ let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
2366
+ if (toSlot == null || toSlot === -1) {
2367
+ try {
2368
+ // Target has no matching input, most likely a converted widget
2369
+ const widget = node.widgets?.find((w) => w.name === input);
2370
+ if (widget && node.convertWidgetToInput?.(widget)) {
2371
+ toSlot = node.inputs?.length - 1;
2372
+ }
2373
+ } catch (error) {}
2374
+ }
2375
+ if (toSlot != null || toSlot !== -1) {
2376
+ fromNode.connect(fromSlot, node, toSlot);
2377
+ }
2378
+ } else {
2379
+ const widget = node.widgets?.find((w) => w.name === input);
2380
+ if (widget) {
2381
+ widget.value = value;
2382
+ widget.callback?.(value);
2383
+ }
2384
+ }
2385
+ }
2386
+ }
2387
+ app.graph.arrange();
2388
+ }, fileName);
2389
+ }
2390
+
2391
+ /**
2392
+ * Registers a Comfy web extension with the app
2393
+ * @param {ComfyExtension} extension
2394
+ */
2395
+ registerExtension(extension) {
2396
+ if (!extension.name) {
2397
+ throw new Error("Extensions must have a 'name' property.");
2398
+ }
2399
+ if (this.extensions.find((ext) => ext.name === extension.name)) {
2400
+ throw new Error(`Extension named '${extension.name}' already registered.`);
2401
+ }
2402
+ this.extensions.push(extension);
2403
+ }
2404
+
2405
+ /**
2406
+ * Refresh combo list on whole nodes
2407
+ */
2408
+ async refreshComboInNodes() {
2409
+ const defs = await api.getNodeDefs();
2410
+
2411
+ for (const nodeId in defs) {
2412
+ this.registerNodeDef(nodeId, defs[nodeId]);
2413
+ }
2414
+
2415
+ for(let nodeNum in this.graph._nodes) {
2416
+ const node = this.graph._nodes[nodeNum];
2417
+ const def = defs[node.type];
2418
+
2419
+ // Allow primitive nodes to handle refresh
2420
+ node.refreshComboInNode?.(defs);
2421
+
2422
+ if(!def)
2423
+ continue;
2424
+
2425
+ for(const widgetNum in node.widgets) {
2426
+ const widget = node.widgets[widgetNum]
2427
+ if(widget.type == "combo" && def["input"]["required"][widget.name] !== undefined) {
2428
+ widget.options.values = def["input"]["required"][widget.name][0];
2429
+
2430
+ if(widget.name != 'image' && !widget.options.values.includes(widget.value)) {
2431
+ widget.value = widget.options.values[0];
2432
+ widget.callback(widget.value);
2433
+ }
2434
+ }
2435
+ }
2436
+ }
2437
+
2438
+ await this.#invokeExtensionsAsync("refreshComboInNodes", defs);
2439
+ }
2440
+
2441
+ resetView() {
2442
+ app.canvas.ds.scale = 1;
2443
+ app.canvas.ds.offset = [0, 0]
2444
+ app.graph.setDirtyCanvas(true, true);
2445
+ }
2446
+
2447
+ /**
2448
+ * Clean current state
2449
+ */
2450
+ clean() {
2451
+ this.nodeOutputs = {};
2452
+ this.nodePreviewImages = {}
2453
+ this.lastNodeErrors = null;
2454
+ this.lastExecutionError = null;
2455
+ this.runningNodeId = null;
2456
+ }
2457
+ }
2458
+
2459
+ export const app = new ComfyApp();
ComfyUI/web/scripts/changeTracker.js ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { api } from "./api.js";
4
+ import { clone } from "./utils.js";
5
+
6
+ export class ChangeTracker {
7
+ static MAX_HISTORY = 50;
8
+ #app;
9
+ undo = [];
10
+ redo = [];
11
+ activeState = null;
12
+ isOurLoad = false;
13
+ /** @type { import("./workflows").ComfyWorkflow | null } */
14
+ workflow;
15
+
16
+ ds;
17
+ nodeOutputs;
18
+
19
+ get app() {
20
+ return this.#app ?? this.workflow.manager.app;
21
+ }
22
+
23
+ constructor(workflow) {
24
+ this.workflow = workflow;
25
+ }
26
+
27
+ #setApp(app) {
28
+ this.#app = app;
29
+ }
30
+
31
+ store() {
32
+ this.ds = { scale: this.app.canvas.ds.scale, offset: [...this.app.canvas.ds.offset] };
33
+ }
34
+
35
+ restore() {
36
+ if (this.ds) {
37
+ this.app.canvas.ds.scale = this.ds.scale;
38
+ this.app.canvas.ds.offset = this.ds.offset;
39
+ }
40
+ if (this.nodeOutputs) {
41
+ this.app.nodeOutputs = this.nodeOutputs;
42
+ }
43
+ }
44
+
45
+ checkState() {
46
+ if (!this.app.graph) return;
47
+
48
+ const currentState = this.app.graph.serialize();
49
+ if (!this.activeState) {
50
+ this.activeState = clone(currentState);
51
+ return;
52
+ }
53
+ if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
54
+ this.undo.push(this.activeState);
55
+ if (this.undo.length > ChangeTracker.MAX_HISTORY) {
56
+ this.undo.shift();
57
+ }
58
+ this.activeState = clone(currentState);
59
+ this.redo.length = 0;
60
+ this.workflow.unsaved = true;
61
+ api.dispatchEvent(new CustomEvent("graphChanged", { detail: this.activeState }));
62
+ }
63
+ }
64
+
65
+ async updateState(source, target) {
66
+ const prevState = source.pop();
67
+ if (prevState) {
68
+ target.push(this.activeState);
69
+ this.isOurLoad = true;
70
+ await this.app.loadGraphData(prevState, false, false, this.workflow);
71
+ this.activeState = prevState;
72
+ }
73
+ }
74
+
75
+ async undoRedo(e) {
76
+ if (e.ctrlKey || e.metaKey) {
77
+ if (e.key === "y") {
78
+ this.updateState(this.redo, this.undo);
79
+ return true;
80
+ } else if (e.key === "z") {
81
+ this.updateState(this.undo, this.redo);
82
+ return true;
83
+ }
84
+ }
85
+ }
86
+
87
+ /** @param { import("./app.js").ComfyApp } app */
88
+ static init(app) {
89
+ const changeTracker = () => app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
90
+ globalTracker.#setApp(app);
91
+
92
+ const loadGraphData = app.loadGraphData;
93
+ app.loadGraphData = async function () {
94
+ const v = await loadGraphData.apply(this, arguments);
95
+ const ct = changeTracker();
96
+ if (ct.isOurLoad) {
97
+ ct.isOurLoad = false;
98
+ } else {
99
+ ct.checkState();
100
+ }
101
+ return v;
102
+ };
103
+
104
+ let keyIgnored = false;
105
+ window.addEventListener(
106
+ "keydown",
107
+ (e) => {
108
+ const activeEl = document.activeElement;
109
+ requestAnimationFrame(async () => {
110
+ let bindInputEl;
111
+ // If we are auto queue in change mode then we do want to trigger on inputs
112
+ if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
113
+ if (activeEl?.tagName === "INPUT" || activeEl?.["type"] === "textarea") {
114
+ // Ignore events on inputs, they have their native history
115
+ return;
116
+ }
117
+ bindInputEl = activeEl;
118
+ }
119
+
120
+ keyIgnored = e.key === "Control" || e.key === "Shift" || e.key === "Alt" || e.key === "Meta";
121
+ if (keyIgnored) return;
122
+
123
+ // Check if this is a ctrl+z ctrl+y
124
+ if (await changeTracker().undoRedo(e)) return;
125
+
126
+ // If our active element is some type of input then handle changes after they're done
127
+ if (ChangeTracker.bindInput(bindInputEl)) return;
128
+ changeTracker().checkState();
129
+ });
130
+ },
131
+ true
132
+ );
133
+
134
+ window.addEventListener("keyup", (e) => {
135
+ if (keyIgnored) {
136
+ keyIgnored = false;
137
+ changeTracker().checkState();
138
+ }
139
+ });
140
+
141
+ // Handle clicking DOM elements (e.g. widgets)
142
+ window.addEventListener("mouseup", () => {
143
+ changeTracker().checkState();
144
+ });
145
+
146
+ // Handle prompt queue event for dynamic widget changes
147
+ api.addEventListener("promptQueued", () => {
148
+ changeTracker().checkState();
149
+ });
150
+
151
+ // Handle litegraph clicks
152
+ const processMouseUp = LGraphCanvas.prototype.processMouseUp;
153
+ LGraphCanvas.prototype.processMouseUp = function (e) {
154
+ const v = processMouseUp.apply(this, arguments);
155
+ changeTracker().checkState();
156
+ return v;
157
+ };
158
+ const processMouseDown = LGraphCanvas.prototype.processMouseDown;
159
+ LGraphCanvas.prototype.processMouseDown = function (e) {
160
+ const v = processMouseDown.apply(this, arguments);
161
+ changeTracker().checkState();
162
+ return v;
163
+ };
164
+
165
+ // Handle litegraph context menu for COMBO widgets
166
+ const close = LiteGraph.ContextMenu.prototype.close;
167
+ LiteGraph.ContextMenu.prototype.close = function (e) {
168
+ const v = close.apply(this, arguments);
169
+ changeTracker().checkState();
170
+ return v;
171
+ };
172
+
173
+ // Detects nodes being added via the node search dialog
174
+ const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded;
175
+ LiteGraph.LGraph.prototype.onNodeAdded = function () {
176
+ const v = onNodeAdded?.apply(this, arguments);
177
+ if (!app?.configuringGraph) {
178
+ const ct = changeTracker();
179
+ if (!ct.isOurLoad) {
180
+ ct.checkState();
181
+ }
182
+ }
183
+ return v;
184
+ };
185
+
186
+ // Store node outputs
187
+ api.addEventListener("executed", ({ detail }) => {
188
+ const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
189
+ if (!prompt?.workflow) return;
190
+ const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
191
+ const output = nodeOutputs[detail.node];
192
+ if (detail.merge && output) {
193
+ for (const k in detail.output ?? {}) {
194
+ const v = output[k];
195
+ if (v instanceof Array) {
196
+ output[k] = v.concat(detail.output[k]);
197
+ } else {
198
+ output[k] = detail.output[k];
199
+ }
200
+ }
201
+ } else {
202
+ nodeOutputs[detail.node] = detail.output;
203
+ }
204
+ });
205
+ }
206
+
207
+ static bindInput(app, activeEl) {
208
+ if (activeEl && activeEl.tagName !== "CANVAS" && activeEl.tagName !== "BODY") {
209
+ for (const evt of ["change", "input", "blur"]) {
210
+ if (`on${evt}` in activeEl) {
211
+ const listener = () => {
212
+ app.workflowManager.activeWorkflow.changeTracker.checkState();
213
+ activeEl.removeEventListener(evt, listener);
214
+ };
215
+ activeEl.addEventListener(evt, listener);
216
+ return true;
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ static graphEqual(a, b, path = "") {
223
+ if (a === b) return true;
224
+
225
+ if (typeof a == "object" && a && typeof b == "object" && b) {
226
+ const keys = Object.getOwnPropertyNames(a);
227
+
228
+ if (keys.length != Object.getOwnPropertyNames(b).length) {
229
+ return false;
230
+ }
231
+
232
+ for (const key of keys) {
233
+ let av = a[key];
234
+ let bv = b[key];
235
+ if (!path && key === "nodes") {
236
+ // Nodes need to be sorted as the order changes when selecting nodes
237
+ av = [...av].sort((a, b) => a.id - b.id);
238
+ bv = [...bv].sort((a, b) => a.id - b.id);
239
+ } else if (path === "extra.ds") {
240
+ // Ignore view changes
241
+ continue;
242
+ }
243
+ if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ return true;
249
+ }
250
+
251
+ return false;
252
+ }
253
+ }
254
+
255
+ const globalTracker = new ChangeTracker({});
ComfyUI/web/scripts/defaultGraph.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const defaultGraph = {
2
+ last_node_id: 9,
3
+ last_link_id: 9,
4
+ nodes: [
5
+ {
6
+ id: 7,
7
+ type: "CLIPTextEncode",
8
+ pos: [413, 389],
9
+ size: { 0: 425.27801513671875, 1: 180.6060791015625 },
10
+ flags: {},
11
+ order: 3,
12
+ mode: 0,
13
+ inputs: [{ name: "clip", type: "CLIP", link: 5 }],
14
+ outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [6], slot_index: 0 }],
15
+ properties: {},
16
+ widgets_values: ["text, watermark"],
17
+ },
18
+ {
19
+ id: 6,
20
+ type: "CLIPTextEncode",
21
+ pos: [415, 186],
22
+ size: { 0: 422.84503173828125, 1: 164.31304931640625 },
23
+ flags: {},
24
+ order: 2,
25
+ mode: 0,
26
+ inputs: [{ name: "clip", type: "CLIP", link: 3 }],
27
+ outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [4], slot_index: 0 }],
28
+ properties: {},
29
+ widgets_values: ["beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"],
30
+ },
31
+ {
32
+ id: 5,
33
+ type: "EmptyLatentImage",
34
+ pos: [473, 609],
35
+ size: { 0: 315, 1: 106 },
36
+ flags: {},
37
+ order: 1,
38
+ mode: 0,
39
+ outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
40
+ properties: {},
41
+ widgets_values: [512, 512, 1],
42
+ },
43
+ {
44
+ id: 3,
45
+ type: "KSampler",
46
+ pos: [863, 186],
47
+ size: { 0: 315, 1: 262 },
48
+ flags: {},
49
+ order: 4,
50
+ mode: 0,
51
+ inputs: [
52
+ { name: "model", type: "MODEL", link: 1 },
53
+ { name: "positive", type: "CONDITIONING", link: 4 },
54
+ { name: "negative", type: "CONDITIONING", link: 6 },
55
+ { name: "latent_image", type: "LATENT", link: 2 },
56
+ ],
57
+ outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
58
+ properties: {},
59
+ widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
60
+ },
61
+ {
62
+ id: 8,
63
+ type: "VAEDecode",
64
+ pos: [1209, 188],
65
+ size: { 0: 210, 1: 46 },
66
+ flags: {},
67
+ order: 5,
68
+ mode: 0,
69
+ inputs: [
70
+ { name: "samples", type: "LATENT", link: 7 },
71
+ { name: "vae", type: "VAE", link: 8 },
72
+ ],
73
+ outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
74
+ properties: {},
75
+ },
76
+ {
77
+ id: 9,
78
+ type: "SaveImage",
79
+ pos: [1451, 189],
80
+ size: { 0: 210, 1: 26 },
81
+ flags: {},
82
+ order: 6,
83
+ mode: 0,
84
+ inputs: [{ name: "images", type: "IMAGE", link: 9 }],
85
+ properties: {},
86
+ },
87
+ {
88
+ id: 4,
89
+ type: "CheckpointLoaderSimple",
90
+ pos: [26, 474],
91
+ size: { 0: 315, 1: 98 },
92
+ flags: {},
93
+ order: 0,
94
+ mode: 0,
95
+ outputs: [
96
+ { name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
97
+ { name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
98
+ { name: "VAE", type: "VAE", links: [8], slot_index: 2 },
99
+ ],
100
+ properties: {},
101
+ widgets_values: ["v1-5-pruned-emaonly.ckpt"],
102
+ },
103
+ ],
104
+ links: [
105
+ [1, 4, 0, 3, 0, "MODEL"],
106
+ [2, 5, 0, 3, 3, "LATENT"],
107
+ [3, 4, 1, 6, 0, "CLIP"],
108
+ [4, 6, 0, 3, 1, "CONDITIONING"],
109
+ [5, 4, 1, 7, 0, "CLIP"],
110
+ [6, 7, 0, 3, 2, "CONDITIONING"],
111
+ [7, 3, 0, 8, 0, "LATENT"],
112
+ [8, 4, 2, 8, 1, "VAE"],
113
+ [9, 8, 0, 9, 0, "IMAGE"],
114
+ ],
115
+ groups: [],
116
+ config: {},
117
+ extra: {},
118
+ version: 0.4,
119
+ };
ComfyUI/web/scripts/domWidget.js ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app, ANIM_PREVIEW_WIDGET } from "./app.js";
2
+
3
+ const SIZE = Symbol();
4
+
5
+ function intersect(a, b) {
6
+ const x = Math.max(a.x, b.x);
7
+ const num1 = Math.min(a.x + a.width, b.x + b.width);
8
+ const y = Math.max(a.y, b.y);
9
+ const num2 = Math.min(a.y + a.height, b.y + b.height);
10
+ if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y];
11
+ else return null;
12
+ }
13
+
14
+ function getClipPath(node, element) {
15
+ const selectedNode = Object.values(app.canvas.selected_nodes)[0];
16
+ if (selectedNode && selectedNode !== node) {
17
+ const elRect = element.getBoundingClientRect();
18
+ const MARGIN = 7;
19
+ const scale = app.canvas.ds.scale;
20
+
21
+ const bounding = selectedNode.getBounding();
22
+ const intersection = intersect(
23
+ { x: elRect.x / scale, y: elRect.y / scale, width: elRect.width / scale, height: elRect.height / scale },
24
+ {
25
+ x: selectedNode.pos[0] + app.canvas.ds.offset[0] - MARGIN,
26
+ y: selectedNode.pos[1] + app.canvas.ds.offset[1] - LiteGraph.NODE_TITLE_HEIGHT - MARGIN,
27
+ width: bounding[2] + MARGIN + MARGIN,
28
+ height: bounding[3] + MARGIN + MARGIN,
29
+ }
30
+ );
31
+
32
+ if (!intersection) {
33
+ return "";
34
+ }
35
+
36
+ const widgetRect = element.getBoundingClientRect();
37
+ const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
38
+ const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
39
+ const clipWidth = intersection[2] + "px";
40
+ const clipHeight = intersection[3] + "px";
41
+ const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
42
+ return path;
43
+ }
44
+ return "";
45
+ }
46
+
47
+ function computeSize(size) {
48
+ if (this.widgets?.[0]?.last_y == null) return;
49
+
50
+ let y = this.widgets[0].last_y;
51
+ let freeSpace = size[1] - y;
52
+
53
+ let widgetHeight = 0;
54
+ let dom = [];
55
+ for (const w of this.widgets) {
56
+ if (w.type === "converted-widget") {
57
+ // Ignore
58
+ delete w.computedHeight;
59
+ } else if (w.computeSize) {
60
+ widgetHeight += w.computeSize()[1] + 4;
61
+ } else if (w.element) {
62
+ // Extract DOM widget size info
63
+ const styles = getComputedStyle(w.element);
64
+ let minHeight = w.options.getMinHeight?.() ?? parseInt(styles.getPropertyValue("--comfy-widget-min-height"));
65
+ let maxHeight = w.options.getMaxHeight?.() ?? parseInt(styles.getPropertyValue("--comfy-widget-max-height"));
66
+
67
+ let prefHeight = w.options.getHeight?.() ?? styles.getPropertyValue("--comfy-widget-height");
68
+ if (prefHeight.endsWith?.("%")) {
69
+ prefHeight = size[1] * (parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100);
70
+ } else {
71
+ prefHeight = parseInt(prefHeight);
72
+ if (isNaN(minHeight)) {
73
+ minHeight = prefHeight;
74
+ }
75
+ }
76
+ if (isNaN(minHeight)) {
77
+ minHeight = 50;
78
+ }
79
+ if (!isNaN(maxHeight)) {
80
+ if (!isNaN(prefHeight)) {
81
+ prefHeight = Math.min(prefHeight, maxHeight);
82
+ } else {
83
+ prefHeight = maxHeight;
84
+ }
85
+ }
86
+ dom.push({
87
+ minHeight,
88
+ prefHeight,
89
+ w,
90
+ });
91
+ } else {
92
+ widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
93
+ }
94
+ }
95
+
96
+ freeSpace -= widgetHeight;
97
+
98
+ // Calculate sizes with all widgets at their min height
99
+ const prefGrow = []; // Nodes that want to grow to their prefd size
100
+ const canGrow = []; // Nodes that can grow to auto size
101
+ let growBy = 0;
102
+ for (const d of dom) {
103
+ freeSpace -= d.minHeight;
104
+ if (isNaN(d.prefHeight)) {
105
+ canGrow.push(d);
106
+ d.w.computedHeight = d.minHeight;
107
+ } else {
108
+ const diff = d.prefHeight - d.minHeight;
109
+ if (diff > 0) {
110
+ prefGrow.push(d);
111
+ growBy += diff;
112
+ d.diff = diff;
113
+ } else {
114
+ d.w.computedHeight = d.minHeight;
115
+ }
116
+ }
117
+ }
118
+
119
+ if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
120
+ // Allocate space for image
121
+ freeSpace -= 220;
122
+ }
123
+
124
+ this.freeWidgetSpace = freeSpace;
125
+
126
+ if (freeSpace < 0) {
127
+ // Not enough space for all widgets so we need to grow
128
+ size[1] -= freeSpace;
129
+ this.graph.setDirtyCanvas(true);
130
+ } else {
131
+ // Share the space between each
132
+ const growDiff = freeSpace - growBy;
133
+ if (growDiff > 0) {
134
+ // All pref sizes can be fulfilled
135
+ freeSpace = growDiff;
136
+ for (const d of prefGrow) {
137
+ d.w.computedHeight = d.prefHeight;
138
+ }
139
+ } else {
140
+ // We need to grow evenly
141
+ const shared = -growDiff / prefGrow.length;
142
+ for (const d of prefGrow) {
143
+ d.w.computedHeight = d.prefHeight - shared;
144
+ }
145
+ freeSpace = 0;
146
+ }
147
+
148
+ if (freeSpace > 0 && canGrow.length) {
149
+ // Grow any that are auto height
150
+ const shared = freeSpace / canGrow.length;
151
+ for (const d of canGrow) {
152
+ d.w.computedHeight += shared;
153
+ }
154
+ }
155
+ }
156
+
157
+ // Position each of the widgets
158
+ for (const w of this.widgets) {
159
+ w.y = y;
160
+ if (w.computedHeight) {
161
+ y += w.computedHeight;
162
+ } else if (w.computeSize) {
163
+ y += w.computeSize()[1] + 4;
164
+ } else {
165
+ y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
166
+ }
167
+ }
168
+ }
169
+
170
+ // Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
171
+ const elementWidgets = new Set();
172
+ const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
173
+ LGraphCanvas.prototype.computeVisibleNodes = function () {
174
+ const visibleNodes = computeVisibleNodes.apply(this, arguments);
175
+ for (const node of app.graph._nodes) {
176
+ if (elementWidgets.has(node)) {
177
+ const hidden = visibleNodes.indexOf(node) === -1;
178
+ for (const w of node.widgets) {
179
+ if (w.element) {
180
+ w.element.hidden = hidden;
181
+ w.element.style.display = hidden ? "none" : undefined;
182
+ if (hidden) {
183
+ w.options.onHide?.(w);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ return visibleNodes;
191
+ };
192
+
193
+ let enableDomClipping = true;
194
+
195
+ export function addDomClippingSetting() {
196
+ app.ui.settings.addSetting({
197
+ id: "Comfy.DOMClippingEnabled",
198
+ name: "Enable DOM element clipping (enabling may reduce performance)",
199
+ type: "boolean",
200
+ defaultValue: enableDomClipping,
201
+ onChange(value) {
202
+ enableDomClipping = !!value;
203
+ },
204
+ });
205
+ }
206
+
207
+ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
208
+ options = { hideOnZoom: true, selectOn: ["focus", "click"], ...options };
209
+
210
+ if (!element.parentElement) {
211
+ document.body.append(element);
212
+ }
213
+ element.hidden = true;
214
+ element.style.display = "none";
215
+
216
+ let mouseDownHandler;
217
+ if (element.blur) {
218
+ mouseDownHandler = (event) => {
219
+ if (!element.contains(event.target)) {
220
+ element.blur();
221
+ }
222
+ };
223
+ document.addEventListener("mousedown", mouseDownHandler);
224
+ }
225
+
226
+ const widget = {
227
+ type,
228
+ name,
229
+ get value() {
230
+ return options.getValue?.() ?? undefined;
231
+ },
232
+ set value(v) {
233
+ options.setValue?.(v);
234
+ widget.callback?.(widget.value);
235
+ },
236
+ draw: function (ctx, node, widgetWidth, y, widgetHeight) {
237
+ if (widget.computedHeight == null) {
238
+ computeSize.call(node, node.size);
239
+ }
240
+
241
+ const hidden =
242
+ node.flags?.collapsed ||
243
+ (!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
244
+ widget.computedHeight <= 0 ||
245
+ widget.type === "converted-widget"||
246
+ widget.type === "hidden";
247
+ element.hidden = hidden;
248
+ element.style.display = hidden ? "none" : null;
249
+ if (hidden) {
250
+ widget.options.onHide?.(widget);
251
+ return;
252
+ }
253
+
254
+ const margin = 10;
255
+ const elRect = ctx.canvas.getBoundingClientRect();
256
+ const transform = new DOMMatrix()
257
+ .scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
258
+ .multiplySelf(ctx.getTransform())
259
+ .translateSelf(margin, margin + y );
260
+
261
+ const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
262
+
263
+ Object.assign(element.style, {
264
+ transformOrigin: "0 0",
265
+ transform: scale,
266
+ left: `${transform.a + transform.e + elRect.left}px`,
267
+ top: `${transform.d + transform.f + elRect.top}px`,
268
+ width: `${widgetWidth - margin * 2}px`,
269
+ height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
270
+ position: "absolute",
271
+ zIndex: app.graph._nodes.indexOf(node),
272
+ });
273
+
274
+ if (enableDomClipping) {
275
+ element.style.clipPath = getClipPath(node, element);
276
+ element.style.willChange = "clip-path";
277
+ }
278
+
279
+ this.options.onDraw?.(widget);
280
+ },
281
+ element,
282
+ options,
283
+ onRemove() {
284
+ if (mouseDownHandler) {
285
+ document.removeEventListener("mousedown", mouseDownHandler);
286
+ }
287
+ element.remove();
288
+ },
289
+ };
290
+
291
+ for (const evt of options.selectOn) {
292
+ element.addEventListener(evt, () => {
293
+ app.canvas.selectNode(this);
294
+ app.canvas.bringToFront(this);
295
+ });
296
+ }
297
+
298
+ this.addCustomWidget(widget);
299
+ elementWidgets.add(this);
300
+
301
+ const collapse = this.collapse;
302
+ this.collapse = function() {
303
+ collapse.apply(this, arguments);
304
+ if(this.flags?.collapsed) {
305
+ element.hidden = true;
306
+ element.style.display = "none";
307
+ }
308
+ }
309
+
310
+ const onRemoved = this.onRemoved;
311
+ this.onRemoved = function () {
312
+ element.remove();
313
+ elementWidgets.delete(this);
314
+ onRemoved?.apply(this, arguments);
315
+ };
316
+
317
+ if (!this[SIZE]) {
318
+ this[SIZE] = true;
319
+ const onResize = this.onResize;
320
+ this.onResize = function (size) {
321
+ options.beforeResize?.call(widget, this);
322
+ computeSize.call(this, size);
323
+ onResize?.apply(this, arguments);
324
+ options.afterResize?.call(widget, this);
325
+ };
326
+ }
327
+
328
+ return widget;
329
+ };
ComfyUI/web/scripts/logging.js ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el, ComfyDialog } from "./ui.js";
2
+ import { api } from "./api.js";
3
+
4
+ $el("style", {
5
+ textContent: `
6
+ .comfy-logging-logs {
7
+ display: grid;
8
+ color: var(--fg-color);
9
+ white-space: pre-wrap;
10
+ }
11
+ .comfy-logging-log {
12
+ display: contents;
13
+ }
14
+ .comfy-logging-title {
15
+ background: var(--tr-even-bg-color);
16
+ font-weight: bold;
17
+ margin-bottom: 5px;
18
+ text-align: center;
19
+ }
20
+ .comfy-logging-log div {
21
+ background: var(--row-bg);
22
+ padding: 5px;
23
+ }
24
+ `,
25
+ parent: document.body,
26
+ });
27
+
28
+ // Stringify function supporting max depth and removal of circular references
29
+ // https://stackoverflow.com/a/57193345
30
+ function stringify(val, depth, replacer, space, onGetObjID) {
31
+ depth = isNaN(+depth) ? 1 : depth;
32
+ var recursMap = new WeakMap();
33
+ function _build(val, depth, o, a, r) {
34
+ // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
35
+ return !val || typeof val != "object"
36
+ ? val
37
+ : ((r = recursMap.has(val)),
38
+ recursMap.set(val, true),
39
+ (a = Array.isArray(val)),
40
+ r
41
+ ? (o = (onGetObjID && onGetObjID(val)) || null)
42
+ : JSON.stringify(val, function (k, v) {
43
+ if (a || depth > 0) {
44
+ if (replacer) v = replacer(k, v);
45
+ if (!k) return (a = Array.isArray(v)), (val = v);
46
+ !o && (o = a ? [] : {});
47
+ o[k] = _build(v, a ? depth : depth - 1);
48
+ }
49
+ }),
50
+ o === void 0 ? (a ? [] : {}) : o);
51
+ }
52
+ return JSON.stringify(_build(val, depth), null, space);
53
+ }
54
+
55
+ const jsonReplacer = (k, v, ui) => {
56
+ if (v instanceof Array && v.length === 1) {
57
+ v = v[0];
58
+ }
59
+ if (v instanceof Date) {
60
+ v = v.toISOString();
61
+ if (ui) {
62
+ v = v.split("T")[1];
63
+ }
64
+ }
65
+ if (v instanceof Error) {
66
+ let err = "";
67
+ if (v.name) err += v.name + "\n";
68
+ if (v.message) err += v.message + "\n";
69
+ if (v.stack) err += v.stack + "\n";
70
+ if (!err) {
71
+ err = v.toString();
72
+ }
73
+ v = err;
74
+ }
75
+ return v;
76
+ };
77
+
78
+ const fileInput = $el("input", {
79
+ type: "file",
80
+ accept: ".json",
81
+ style: { display: "none" },
82
+ parent: document.body,
83
+ });
84
+
85
+ class ComfyLoggingDialog extends ComfyDialog {
86
+ constructor(logging) {
87
+ super();
88
+ this.logging = logging;
89
+ }
90
+
91
+ clear() {
92
+ this.logging.clear();
93
+ this.show();
94
+ }
95
+
96
+ export() {
97
+ const blob = new Blob([stringify([...this.logging.entries], 20, jsonReplacer, "\t")], {
98
+ type: "application/json",
99
+ });
100
+ const url = URL.createObjectURL(blob);
101
+ const a = $el("a", {
102
+ href: url,
103
+ download: `comfyui-logs-${Date.now()}.json`,
104
+ style: { display: "none" },
105
+ parent: document.body,
106
+ });
107
+ a.click();
108
+ setTimeout(function () {
109
+ a.remove();
110
+ window.URL.revokeObjectURL(url);
111
+ }, 0);
112
+ }
113
+
114
+ import() {
115
+ fileInput.onchange = () => {
116
+ const reader = new FileReader();
117
+ reader.onload = () => {
118
+ fileInput.remove();
119
+ try {
120
+ const obj = JSON.parse(reader.result);
121
+ if (obj instanceof Array) {
122
+ this.show(obj);
123
+ } else {
124
+ throw new Error("Invalid file selected.");
125
+ }
126
+ } catch (error) {
127
+ alert("Unable to load logs: " + error.message);
128
+ }
129
+ };
130
+ reader.readAsText(fileInput.files[0]);
131
+ };
132
+ fileInput.click();
133
+ }
134
+
135
+ createButtons() {
136
+ return [
137
+ $el("button", {
138
+ type: "button",
139
+ textContent: "Clear",
140
+ onclick: () => this.clear(),
141
+ }),
142
+ $el("button", {
143
+ type: "button",
144
+ textContent: "Export logs...",
145
+ onclick: () => this.export(),
146
+ }),
147
+ $el("button", {
148
+ type: "button",
149
+ textContent: "View exported logs...",
150
+ onclick: () => this.import(),
151
+ }),
152
+ ...super.createButtons(),
153
+ ];
154
+ }
155
+
156
+ getTypeColor(type) {
157
+ switch (type) {
158
+ case "error":
159
+ return "red";
160
+ case "warn":
161
+ return "orange";
162
+ case "debug":
163
+ return "dodgerblue";
164
+ }
165
+ }
166
+
167
+ show(entries) {
168
+ if (!entries) entries = this.logging.entries;
169
+ this.element.style.width = "100%";
170
+ const cols = {
171
+ source: "Source",
172
+ type: "Type",
173
+ timestamp: "Timestamp",
174
+ message: "Message",
175
+ };
176
+ const keys = Object.keys(cols);
177
+ const headers = Object.values(cols).map((title) =>
178
+ $el("div.comfy-logging-title", {
179
+ textContent: title,
180
+ })
181
+ );
182
+ const rows = entries.map((entry, i) => {
183
+ return $el(
184
+ "div.comfy-logging-log",
185
+ {
186
+ $: (el) => el.style.setProperty("--row-bg", `var(--tr-${i % 2 ? "even" : "odd"}-bg-color)`),
187
+ },
188
+ keys.map((key) => {
189
+ let v = entry[key];
190
+ let color;
191
+ if (key === "type") {
192
+ color = this.getTypeColor(v);
193
+ } else {
194
+ v = jsonReplacer(key, v, true);
195
+
196
+ if (typeof v === "object") {
197
+ v = stringify(v, 5, jsonReplacer, " ");
198
+ }
199
+ }
200
+
201
+ return $el("div", {
202
+ style: {
203
+ color,
204
+ },
205
+ textContent: v,
206
+ });
207
+ })
208
+ );
209
+ });
210
+
211
+ const grid = $el(
212
+ "div.comfy-logging-logs",
213
+ {
214
+ style: {
215
+ gridTemplateColumns: `repeat(${headers.length}, 1fr)`,
216
+ },
217
+ },
218
+ [...headers, ...rows]
219
+ );
220
+ const els = [grid];
221
+ if (!this.logging.enabled) {
222
+ els.unshift(
223
+ $el("h3", {
224
+ style: { textAlign: "center" },
225
+ textContent: "Logging is disabled",
226
+ })
227
+ );
228
+ }
229
+ super.show($el("div", els));
230
+ }
231
+ }
232
+
233
+ export class ComfyLogging {
234
+ /**
235
+ * @type Array<{ source: string, type: string, timestamp: Date, message: any }>
236
+ */
237
+ entries = [];
238
+
239
+ #enabled;
240
+ #console = {};
241
+
242
+ get enabled() {
243
+ return this.#enabled;
244
+ }
245
+
246
+ set enabled(value) {
247
+ if (value === this.#enabled) return;
248
+ if (value) {
249
+ this.patchConsole();
250
+ } else {
251
+ this.unpatchConsole();
252
+ }
253
+ this.#enabled = value;
254
+ }
255
+
256
+ constructor(app) {
257
+ this.app = app;
258
+
259
+ this.dialog = new ComfyLoggingDialog(this);
260
+ this.addSetting();
261
+ this.catchUnhandled();
262
+ this.addInitData();
263
+ }
264
+
265
+ addSetting() {
266
+ const settingId = "Comfy.Logging.Enabled";
267
+ const htmlSettingId = settingId.replaceAll(".", "-");
268
+ const setting = this.app.ui.settings.addSetting({
269
+ id: settingId,
270
+ name: settingId,
271
+ defaultValue: true,
272
+ onChange: (value) => {
273
+ this.enabled = value;
274
+ },
275
+ type: (name, setter, value) => {
276
+ return $el("tr", [
277
+ $el("td", [
278
+ $el("label", {
279
+ textContent: "Logging",
280
+ for: htmlSettingId,
281
+ }),
282
+ ]),
283
+ $el("td", [
284
+ $el("input", {
285
+ id: htmlSettingId,
286
+ type: "checkbox",
287
+ checked: value,
288
+ onchange: (event) => {
289
+ setter(event.target.checked);
290
+ },
291
+ }),
292
+ $el("button", {
293
+ textContent: "View Logs",
294
+ onclick: () => {
295
+ this.app.ui.settings.element.close();
296
+ this.dialog.show();
297
+ },
298
+ style: {
299
+ fontSize: "14px",
300
+ display: "block",
301
+ marginTop: "5px",
302
+ },
303
+ }),
304
+ ]),
305
+ ]);
306
+ },
307
+ });
308
+ this.enabled = setting.value;
309
+ }
310
+
311
+ patchConsole() {
312
+ // Capture common console outputs
313
+ const self = this;
314
+ for (const type of ["log", "warn", "error", "debug"]) {
315
+ const orig = console[type];
316
+ this.#console[type] = orig;
317
+ console[type] = function () {
318
+ orig.apply(console, arguments);
319
+ self.addEntry("console", type, ...arguments);
320
+ };
321
+ }
322
+ }
323
+
324
+ unpatchConsole() {
325
+ // Restore original console functions
326
+ for (const type of Object.keys(this.#console)) {
327
+ console[type] = this.#console[type];
328
+ }
329
+ this.#console = {};
330
+ }
331
+
332
+ catchUnhandled() {
333
+ // Capture uncaught errors
334
+ window.addEventListener("error", (e) => {
335
+ this.addEntry("window", "error", e.error ?? "Unknown error");
336
+ return false;
337
+ });
338
+
339
+ window.addEventListener("unhandledrejection", (e) => {
340
+ this.addEntry("unhandledrejection", "error", e.reason ?? "Unknown error");
341
+ });
342
+ }
343
+
344
+ clear() {
345
+ this.entries = [];
346
+ }
347
+
348
+ addEntry(source, type, ...args) {
349
+ if (this.enabled) {
350
+ this.entries.push({
351
+ source,
352
+ type,
353
+ timestamp: new Date(),
354
+ message: args,
355
+ });
356
+ }
357
+ }
358
+
359
+ log(source, ...args) {
360
+ this.addEntry(source, "log", ...args);
361
+ }
362
+
363
+ async addInitData() {
364
+ if (!this.enabled) return;
365
+ const source = "ComfyUI.Logging";
366
+ this.addEntry(source, "debug", { UserAgent: navigator.userAgent });
367
+ const systemStats = await api.getSystemStats();
368
+ this.addEntry(source, "debug", systemStats);
369
+ }
370
+ }
ComfyUI/web/scripts/pnginfo.js ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "./api.js";
2
+
3
+ export function getPngMetadata(file) {
4
+ return new Promise((r) => {
5
+ const reader = new FileReader();
6
+ reader.onload = (event) => {
7
+ // Get the PNG data as a Uint8Array
8
+ const pngData = new Uint8Array(event.target.result);
9
+ const dataView = new DataView(pngData.buffer);
10
+
11
+ // Check that the PNG signature is present
12
+ if (dataView.getUint32(0) !== 0x89504e47) {
13
+ console.error("Not a valid PNG file");
14
+ r();
15
+ return;
16
+ }
17
+
18
+ // Start searching for chunks after the PNG signature
19
+ let offset = 8;
20
+ let txt_chunks = {};
21
+ // Loop through the chunks in the PNG file
22
+ while (offset < pngData.length) {
23
+ // Get the length of the chunk
24
+ const length = dataView.getUint32(offset);
25
+ // Get the chunk type
26
+ const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
27
+ if (type === "tEXt" || type == "comf" || type === "iTXt") {
28
+ // Get the keyword
29
+ let keyword_end = offset + 8;
30
+ while (pngData[keyword_end] !== 0) {
31
+ keyword_end++;
32
+ }
33
+ const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end));
34
+ // Get the text
35
+ const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length);
36
+ const contentJson = new TextDecoder("utf-8").decode(contentArraySegment);
37
+ txt_chunks[keyword] = contentJson;
38
+ }
39
+
40
+ offset += 12 + length;
41
+ }
42
+
43
+ r(txt_chunks);
44
+ };
45
+
46
+ reader.readAsArrayBuffer(file);
47
+ });
48
+ }
49
+
50
+ function parseExifData(exifData) {
51
+ // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
52
+ const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === "II";
53
+
54
+ // Function to read 16-bit and 32-bit integers from binary data
55
+ function readInt(offset, isLittleEndian, length) {
56
+ let arr = exifData.slice(offset, offset + length)
57
+ if (length === 2) {
58
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian);
59
+ } else if (length === 4) {
60
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian);
61
+ }
62
+ }
63
+
64
+ // Read the offset to the first IFD (Image File Directory)
65
+ const ifdOffset = readInt(4, isLittleEndian, 4);
66
+
67
+ function parseIFD(offset) {
68
+ const numEntries = readInt(offset, isLittleEndian, 2);
69
+ const result = {};
70
+
71
+ for (let i = 0; i < numEntries; i++) {
72
+ const entryOffset = offset + 2 + i * 12;
73
+ const tag = readInt(entryOffset, isLittleEndian, 2);
74
+ const type = readInt(entryOffset + 2, isLittleEndian, 2);
75
+ const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
76
+ const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
77
+
78
+ // Read the value(s) based on the data type
79
+ let value;
80
+ if (type === 2) {
81
+ // ASCII string
82
+ value = String.fromCharCode(...exifData.slice(valueOffset, valueOffset + numValues - 1));
83
+ }
84
+
85
+ result[tag] = value;
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ // Parse the first IFD
92
+ const ifdData = parseIFD(ifdOffset);
93
+ return ifdData;
94
+ }
95
+
96
+ function splitValues(input) {
97
+ var output = {};
98
+ for (var key in input) {
99
+ var value = input[key];
100
+ var splitValues = value.split(':', 2);
101
+ output[splitValues[0]] = splitValues[1];
102
+ }
103
+ return output;
104
+ }
105
+
106
+ export function getWebpMetadata(file) {
107
+ return new Promise((r) => {
108
+ const reader = new FileReader();
109
+ reader.onload = (event) => {
110
+ const webp = new Uint8Array(event.target.result);
111
+ const dataView = new DataView(webp.buffer);
112
+
113
+ // Check that the WEBP signature is present
114
+ if (dataView.getUint32(0) !== 0x52494646 || dataView.getUint32(8) !== 0x57454250) {
115
+ console.error("Not a valid WEBP file");
116
+ r();
117
+ return;
118
+ }
119
+
120
+ // Start searching for chunks after the WEBP signature
121
+ let offset = 12;
122
+ let txt_chunks = {};
123
+ // Loop through the chunks in the WEBP file
124
+ while (offset < webp.length) {
125
+ const chunk_length = dataView.getUint32(offset + 4, true);
126
+ const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4));
127
+ if (chunk_type === "EXIF") {
128
+ if (String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == "Exif\0\0") {
129
+ offset += 6;
130
+ }
131
+ let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length));
132
+ for (var key in data) {
133
+ var value = data[key];
134
+ let index = value.indexOf(':');
135
+ txt_chunks[value.slice(0, index)] = value.slice(index + 1);
136
+ }
137
+ break;
138
+ }
139
+
140
+ offset += 8 + chunk_length;
141
+ }
142
+
143
+ r(txt_chunks);
144
+ };
145
+
146
+ reader.readAsArrayBuffer(file);
147
+ });
148
+ }
149
+
150
+ export function getLatentMetadata(file) {
151
+ return new Promise((r) => {
152
+ const reader = new FileReader();
153
+ reader.onload = (event) => {
154
+ const safetensorsData = new Uint8Array(event.target.result);
155
+ const dataView = new DataView(safetensorsData.buffer);
156
+ let header_size = dataView.getUint32(0, true);
157
+ let offset = 8;
158
+ let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size)));
159
+ r(header.__metadata__);
160
+ };
161
+
162
+ var slice = file.slice(0, 1024 * 1024 * 4);
163
+ reader.readAsArrayBuffer(slice);
164
+ });
165
+ }
166
+
167
+
168
+ function getString(dataView, offset, length) {
169
+ let string = '';
170
+ for (let i = 0; i < length; i++) {
171
+ string += String.fromCharCode(dataView.getUint8(offset + i));
172
+ }
173
+ return string;
174
+ }
175
+
176
+ // Function to parse the Vorbis Comment block
177
+ function parseVorbisComment(dataView) {
178
+ let offset = 0;
179
+ const vendorLength = dataView.getUint32(offset, true);
180
+ offset += 4;
181
+ const vendorString = getString(dataView, offset, vendorLength);
182
+ offset += vendorLength;
183
+
184
+ const userCommentListLength = dataView.getUint32(offset, true);
185
+ offset += 4;
186
+ const comments = {};
187
+ for (let i = 0; i < userCommentListLength; i++) {
188
+ const commentLength = dataView.getUint32(offset, true);
189
+ offset += 4;
190
+ const comment = getString(dataView, offset, commentLength);
191
+ offset += commentLength;
192
+
193
+ const [key, value] = comment.split('=');
194
+
195
+ comments[key] = value;
196
+ }
197
+
198
+ return comments;
199
+ }
200
+
201
+ // Function to read a FLAC file and parse Vorbis comments
202
+ export function getFlacMetadata(file) {
203
+ return new Promise((r) => {
204
+ const reader = new FileReader();
205
+ reader.onload = function(event) {
206
+ const arrayBuffer = event.target.result;
207
+ const dataView = new DataView(arrayBuffer);
208
+
209
+ // Verify the FLAC signature
210
+ const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4));
211
+ if (signature !== 'fLaC') {
212
+ console.error('Not a valid FLAC file');
213
+ return;
214
+ }
215
+
216
+ // Parse metadata blocks
217
+ let offset = 4;
218
+ let vorbisComment = null;
219
+ while (offset < dataView.byteLength) {
220
+ const isLastBlock = dataView.getUint8(offset) & 0x80;
221
+ const blockType = dataView.getUint8(offset) & 0x7F;
222
+ const blockSize = dataView.getUint32(offset, false) & 0xFFFFFF;
223
+ offset += 4;
224
+
225
+ if (blockType === 4) { // Vorbis Comment block type
226
+ vorbisComment = parseVorbisComment(new DataView(arrayBuffer, offset, blockSize));
227
+ }
228
+
229
+ offset += blockSize;
230
+ if (isLastBlock) break;
231
+ }
232
+
233
+ r(vorbisComment);
234
+ };
235
+ reader.readAsArrayBuffer(file);
236
+ });
237
+ }
238
+
239
+ export async function importA1111(graph, parameters) {
240
+ const p = parameters.lastIndexOf("\nSteps:");
241
+ if (p > -1) {
242
+ const embeddings = await api.getEmbeddings();
243
+ const opts = parameters
244
+ .substr(p)
245
+ .split("\n")[1]
246
+ .match(new RegExp("\\s*([^:]+:\\s*([^\"\\{].*?|\".*?\"|\\{.*?\\}))\\s*(,|$)", "g"))
247
+ .reduce((p, n) => {
248
+ const s = n.split(":");
249
+ if (s[1].endsWith(',')) {
250
+ s[1] = s[1].substr(0, s[1].length -1);
251
+ }
252
+ p[s[0].trim().toLowerCase()] = s[1].trim();
253
+ return p;
254
+ }, {});
255
+ const p2 = parameters.lastIndexOf("\nNegative prompt:", p);
256
+ if (p2 > -1) {
257
+ let positive = parameters.substr(0, p2).trim();
258
+ let negative = parameters.substring(p2 + 18, p).trim();
259
+
260
+ const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple");
261
+ const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer");
262
+ const positiveNode = LiteGraph.createNode("CLIPTextEncode");
263
+ const negativeNode = LiteGraph.createNode("CLIPTextEncode");
264
+ const samplerNode = LiteGraph.createNode("KSampler");
265
+ const imageNode = LiteGraph.createNode("EmptyLatentImage");
266
+ const vaeNode = LiteGraph.createNode("VAEDecode");
267
+ const vaeLoaderNode = LiteGraph.createNode("VAELoader");
268
+ const saveNode = LiteGraph.createNode("SaveImage");
269
+ let hrSamplerNode = null;
270
+ let hrSteps = null;
271
+
272
+ const ceil64 = (v) => Math.ceil(v / 64) * 64;
273
+
274
+ function getWidget(node, name) {
275
+ return node.widgets.find((w) => w.name === name);
276
+ }
277
+
278
+ function setWidgetValue(node, name, value, isOptionPrefix) {
279
+ const w = getWidget(node, name);
280
+ if (isOptionPrefix) {
281
+ const o = w.options.values.find((w) => w.startsWith(value));
282
+ if (o) {
283
+ w.value = o;
284
+ } else {
285
+ console.warn(`Unknown value '${value}' for widget '${name}'`, node);
286
+ w.value = value;
287
+ }
288
+ } else {
289
+ w.value = value;
290
+ }
291
+ }
292
+
293
+ function createLoraNodes(clipNode, text, prevClip, prevModel) {
294
+ const loras = [];
295
+ text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) {
296
+ const s = c.split(":");
297
+ const weight = parseFloat(s[1]);
298
+ if (isNaN(weight)) {
299
+ console.warn("Invalid LORA", m);
300
+ } else {
301
+ loras.push({ name: s[0], weight });
302
+ }
303
+ return "";
304
+ });
305
+
306
+ for (const l of loras) {
307
+ const loraNode = LiteGraph.createNode("LoraLoader");
308
+ graph.add(loraNode);
309
+ setWidgetValue(loraNode, "lora_name", l.name, true);
310
+ setWidgetValue(loraNode, "strength_model", l.weight);
311
+ setWidgetValue(loraNode, "strength_clip", l.weight);
312
+ prevModel.node.connect(prevModel.index, loraNode, 0);
313
+ prevClip.node.connect(prevClip.index, loraNode, 1);
314
+ prevModel = { node: loraNode, index: 0 };
315
+ prevClip = { node: loraNode, index: 1 };
316
+ }
317
+
318
+ prevClip.node.connect(1, clipNode, 0);
319
+ prevModel.node.connect(0, samplerNode, 0);
320
+ if (hrSamplerNode) {
321
+ prevModel.node.connect(0, hrSamplerNode, 0);
322
+ }
323
+
324
+ return { text, prevModel, prevClip };
325
+ }
326
+
327
+ function replaceEmbeddings(text) {
328
+ if(!embeddings.length) return text;
329
+ return text.replaceAll(
330
+ new RegExp(
331
+ "\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b",
332
+ "ig"
333
+ ),
334
+ "embedding:$1"
335
+ );
336
+ }
337
+
338
+ function popOpt(name) {
339
+ const v = opts[name];
340
+ delete opts[name];
341
+ return v;
342
+ }
343
+
344
+ graph.clear();
345
+ graph.add(ckptNode);
346
+ graph.add(clipSkipNode);
347
+ graph.add(positiveNode);
348
+ graph.add(negativeNode);
349
+ graph.add(samplerNode);
350
+ graph.add(imageNode);
351
+ graph.add(vaeNode);
352
+ graph.add(vaeLoaderNode);
353
+ graph.add(saveNode);
354
+
355
+ ckptNode.connect(1, clipSkipNode, 0);
356
+ clipSkipNode.connect(0, positiveNode, 0);
357
+ clipSkipNode.connect(0, negativeNode, 0);
358
+ ckptNode.connect(0, samplerNode, 0);
359
+ positiveNode.connect(0, samplerNode, 1);
360
+ negativeNode.connect(0, samplerNode, 2);
361
+ imageNode.connect(0, samplerNode, 3);
362
+ vaeNode.connect(0, saveNode, 0);
363
+ samplerNode.connect(0, vaeNode, 0);
364
+ vaeLoaderNode.connect(0, vaeNode, 1);
365
+
366
+ const handlers = {
367
+ model(v) {
368
+ setWidgetValue(ckptNode, "ckpt_name", v, true);
369
+ },
370
+ "vae"(v) {
371
+ setWidgetValue(vaeLoaderNode, "vae_name", v, true);
372
+ },
373
+ "cfg scale"(v) {
374
+ setWidgetValue(samplerNode, "cfg", +v);
375
+ },
376
+ "clip skip"(v) {
377
+ setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v);
378
+ },
379
+ sampler(v) {
380
+ let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_");
381
+ if (name.includes("karras")) {
382
+ name = name.replace("karras", "").replace(/_+$/, "");
383
+ setWidgetValue(samplerNode, "scheduler", "karras");
384
+ } else {
385
+ setWidgetValue(samplerNode, "scheduler", "normal");
386
+ }
387
+ const w = getWidget(samplerNode, "sampler_name");
388
+ const o = w.options.values.find((w) => w === name || w === "sample_" + name);
389
+ if (o) {
390
+ setWidgetValue(samplerNode, "sampler_name", o);
391
+ }
392
+ },
393
+ size(v) {
394
+ const wxh = v.split("x");
395
+ const w = ceil64(+wxh[0]);
396
+ const h = ceil64(+wxh[1]);
397
+ const hrUp = popOpt("hires upscale");
398
+ const hrSz = popOpt("hires resize");
399
+ hrSteps = popOpt("hires steps");
400
+ let hrMethod = popOpt("hires upscaler");
401
+
402
+ setWidgetValue(imageNode, "width", w);
403
+ setWidgetValue(imageNode, "height", h);
404
+
405
+ if (hrUp || hrSz) {
406
+ let uw, uh;
407
+ if (hrUp) {
408
+ uw = w * hrUp;
409
+ uh = h * hrUp;
410
+ } else {
411
+ const s = hrSz.split("x");
412
+ uw = +s[0];
413
+ uh = +s[1];
414
+ }
415
+
416
+ let upscaleNode;
417
+ let latentNode;
418
+
419
+ if (hrMethod.startsWith("Latent")) {
420
+ latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale");
421
+ graph.add(upscaleNode);
422
+ samplerNode.connect(0, upscaleNode, 0);
423
+
424
+ switch (hrMethod) {
425
+ case "Latent (nearest-exact)":
426
+ hrMethod = "nearest-exact";
427
+ break;
428
+ }
429
+ setWidgetValue(upscaleNode, "upscale_method", hrMethod, true);
430
+ } else {
431
+ const decode = LiteGraph.createNode("VAEDecodeTiled");
432
+ graph.add(decode);
433
+ samplerNode.connect(0, decode, 0);
434
+ vaeLoaderNode.connect(0, decode, 1);
435
+
436
+ const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader");
437
+ graph.add(upscaleLoaderNode);
438
+ setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true);
439
+
440
+ const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel");
441
+ graph.add(modelUpscaleNode);
442
+ decode.connect(0, modelUpscaleNode, 1);
443
+ upscaleLoaderNode.connect(0, modelUpscaleNode, 0);
444
+
445
+ upscaleNode = LiteGraph.createNode("ImageScale");
446
+ graph.add(upscaleNode);
447
+ modelUpscaleNode.connect(0, upscaleNode, 0);
448
+
449
+ const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled"));
450
+ graph.add(vaeEncodeNode);
451
+ upscaleNode.connect(0, vaeEncodeNode, 0);
452
+ vaeLoaderNode.connect(0, vaeEncodeNode, 1);
453
+ }
454
+
455
+ setWidgetValue(upscaleNode, "width", ceil64(uw));
456
+ setWidgetValue(upscaleNode, "height", ceil64(uh));
457
+
458
+ hrSamplerNode = LiteGraph.createNode("KSampler");
459
+ graph.add(hrSamplerNode);
460
+ ckptNode.connect(0, hrSamplerNode, 0);
461
+ positiveNode.connect(0, hrSamplerNode, 1);
462
+ negativeNode.connect(0, hrSamplerNode, 2);
463
+ latentNode.connect(0, hrSamplerNode, 3);
464
+ hrSamplerNode.connect(0, vaeNode, 0);
465
+ }
466
+ },
467
+ steps(v) {
468
+ setWidgetValue(samplerNode, "steps", +v);
469
+ },
470
+ seed(v) {
471
+ setWidgetValue(samplerNode, "seed", +v);
472
+ },
473
+ };
474
+
475
+ for (const opt in opts) {
476
+ if (opt in handlers) {
477
+ handlers[opt](popOpt(opt));
478
+ }
479
+ }
480
+
481
+ if (hrSamplerNode) {
482
+ setWidgetValue(hrSamplerNode, "steps", hrSteps? +hrSteps : getWidget(samplerNode, "steps").value);
483
+ setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value);
484
+ setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value);
485
+ setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value);
486
+ setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1"));
487
+ }
488
+
489
+ let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 });
490
+ positive = n.text;
491
+ n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel);
492
+ negative = n.text;
493
+
494
+ setWidgetValue(positiveNode, "text", replaceEmbeddings(positive));
495
+ setWidgetValue(negativeNode, "text", replaceEmbeddings(negative));
496
+
497
+ graph.arrange();
498
+
499
+ for (const opt of ["model hash", "ensd", "version", "vae hash", "ti hashes", "lora hashes", "hashes"]) {
500
+ delete opts[opt];
501
+ }
502
+
503
+ console.warn("Unhandled parameters:", opts);
504
+ }
505
+ }
506
+ }
ComfyUI/web/scripts/ui.js ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "./api.js";
2
+ import { ComfyDialog as _ComfyDialog } from "./ui/dialog.js";
3
+ import { toggleSwitch } from "./ui/toggleSwitch.js";
4
+ import { ComfySettingsDialog } from "./ui/settings.js";
5
+
6
+ export const ComfyDialog = _ComfyDialog;
7
+
8
+ /**
9
+ * @template { string | (keyof HTMLElementTagNameMap) } K
10
+ * @typedef { K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement } ElementType
11
+ */
12
+
13
+ /**
14
+ * @template { string | (keyof HTMLElementTagNameMap) } K
15
+ * @param { K } tag HTML Element Tag and optional classes e.g. div.class1.class2
16
+ * @param { string | Element | Element[] | ({
17
+ * parent?: Element,
18
+ * $?: (el: ElementType<K>) => void,
19
+ * dataset?: DOMStringMap,
20
+ * style?: Partial<CSSStyleDeclaration>,
21
+ * for?: string
22
+ * } & Omit<Partial<ElementType<K>>, "style">) | undefined } [propsOrChildren]
23
+ * @param { string | Element | Element[] | undefined } [children]
24
+ * @returns { ElementType<K> }
25
+ */
26
+ export function $el(tag, propsOrChildren, children) {
27
+ const split = tag.split(".");
28
+ const element = document.createElement(split.shift());
29
+ if (split.length > 0) {
30
+ element.classList.add(...split);
31
+ }
32
+
33
+ if (propsOrChildren) {
34
+ if (typeof propsOrChildren === "string") {
35
+ propsOrChildren = { textContent: propsOrChildren };
36
+ } else if (propsOrChildren instanceof Element) {
37
+ propsOrChildren = [propsOrChildren];
38
+ }
39
+ if (Array.isArray(propsOrChildren)) {
40
+ element.append(...propsOrChildren);
41
+ } else {
42
+ const {parent, $: cb, dataset, style} = propsOrChildren;
43
+ delete propsOrChildren.parent;
44
+ delete propsOrChildren.$;
45
+ delete propsOrChildren.dataset;
46
+ delete propsOrChildren.style;
47
+
48
+ if (Object.hasOwn(propsOrChildren, "for")) {
49
+ element.setAttribute("for", propsOrChildren.for)
50
+ }
51
+
52
+ if (style) {
53
+ Object.assign(element.style, style);
54
+ }
55
+
56
+ if (dataset) {
57
+ Object.assign(element.dataset, dataset);
58
+ }
59
+
60
+ Object.assign(element, propsOrChildren);
61
+ if (children) {
62
+ element.append(...(children instanceof Array ? children.filter(Boolean) : [children]));
63
+ }
64
+
65
+ if (parent) {
66
+ parent.append(element);
67
+ }
68
+
69
+ if (cb) {
70
+ cb(element);
71
+ }
72
+ }
73
+ }
74
+ return element;
75
+ }
76
+
77
+ function dragElement(dragEl, settings) {
78
+ var posDiffX = 0,
79
+ posDiffY = 0,
80
+ posStartX = 0,
81
+ posStartY = 0,
82
+ newPosX = 0,
83
+ newPosY = 0;
84
+ if (dragEl.getElementsByClassName("drag-handle")[0]) {
85
+ // if present, the handle is where you move the DIV from:
86
+ dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown;
87
+ } else {
88
+ // otherwise, move the DIV from anywhere inside the DIV:
89
+ dragEl.onmousedown = dragMouseDown;
90
+ }
91
+
92
+ // When the element resizes (e.g. view queue) ensure it is still in the windows bounds
93
+ const resizeObserver = new ResizeObserver(() => {
94
+ ensureInBounds();
95
+ }).observe(dragEl);
96
+
97
+ function ensureInBounds() {
98
+ try {
99
+ newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft));
100
+ newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop));
101
+
102
+ positionElement();
103
+ }
104
+ catch(exception){
105
+ // robust
106
+ }
107
+ }
108
+
109
+ function positionElement() {
110
+ if(dragEl.style.display === "none") return;
111
+
112
+ const halfWidth = document.body.clientWidth / 2;
113
+ const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
114
+
115
+ // set the element's new position:
116
+ if (anchorRight) {
117
+ dragEl.style.left = "unset";
118
+ dragEl.style.right = document.body.clientWidth - newPosX - dragEl.clientWidth + "px";
119
+ } else {
120
+ dragEl.style.left = newPosX + "px";
121
+ dragEl.style.right = "unset";
122
+ }
123
+
124
+ dragEl.style.top = newPosY + "px";
125
+ dragEl.style.bottom = "unset";
126
+
127
+ if (savePos) {
128
+ localStorage.setItem(
129
+ "Comfy.MenuPosition",
130
+ JSON.stringify({
131
+ x: dragEl.offsetLeft,
132
+ y: dragEl.offsetTop,
133
+ })
134
+ );
135
+ }
136
+ }
137
+
138
+ function restorePos() {
139
+ let pos = localStorage.getItem("Comfy.MenuPosition");
140
+ if (pos) {
141
+ pos = JSON.parse(pos);
142
+ newPosX = pos.x;
143
+ newPosY = pos.y;
144
+ positionElement();
145
+ ensureInBounds();
146
+ }
147
+ }
148
+
149
+ let savePos = undefined;
150
+ settings.addSetting({
151
+ id: "Comfy.MenuPosition",
152
+ name: "Save menu position",
153
+ type: "boolean",
154
+ defaultValue: savePos,
155
+ onChange(value) {
156
+ if (savePos === undefined && value) {
157
+ restorePos();
158
+ }
159
+ savePos = value;
160
+ },
161
+ });
162
+
163
+ function dragMouseDown(e) {
164
+ e = e || window.event;
165
+ e.preventDefault();
166
+ // get the mouse cursor position at startup:
167
+ posStartX = e.clientX;
168
+ posStartY = e.clientY;
169
+ document.onmouseup = closeDragElement;
170
+ // call a function whenever the cursor moves:
171
+ document.onmousemove = elementDrag;
172
+ }
173
+
174
+ function elementDrag(e) {
175
+ e = e || window.event;
176
+ e.preventDefault();
177
+
178
+ dragEl.classList.add("comfy-menu-manual-pos");
179
+
180
+ // calculate the new cursor position:
181
+ posDiffX = e.clientX - posStartX;
182
+ posDiffY = e.clientY - posStartY;
183
+ posStartX = e.clientX;
184
+ posStartY = e.clientY;
185
+
186
+ newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft + posDiffX));
187
+ newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop + posDiffY));
188
+
189
+ positionElement();
190
+ }
191
+
192
+ window.addEventListener("resize", () => {
193
+ ensureInBounds();
194
+ });
195
+
196
+ function closeDragElement() {
197
+ // stop moving when mouse button is released:
198
+ document.onmouseup = null;
199
+ document.onmousemove = null;
200
+ }
201
+
202
+ return restorePos;
203
+ }
204
+
205
+ class ComfyList {
206
+ #type;
207
+ #text;
208
+ #reverse;
209
+
210
+ constructor(text, type, reverse) {
211
+ this.#text = text;
212
+ this.#type = type || text.toLowerCase();
213
+ this.#reverse = reverse || false;
214
+ this.element = $el("div.comfy-list");
215
+ this.element.style.display = "none";
216
+ }
217
+
218
+ get visible() {
219
+ return this.element.style.display !== "none";
220
+ }
221
+
222
+ async load() {
223
+ const items = await api.getItems(this.#type);
224
+ this.element.replaceChildren(
225
+ ...Object.keys(items).flatMap((section) => [
226
+ $el("h4", {
227
+ textContent: section,
228
+ }),
229
+ $el("div.comfy-list-items", [
230
+ ...(this.#reverse ? items[section].reverse() : items[section]).map((item) => {
231
+ // Allow items to specify a custom remove action (e.g. for interrupt current prompt)
232
+ const removeAction = item.remove || {
233
+ name: "Delete",
234
+ cb: () => api.deleteItem(this.#type, item.prompt[1]),
235
+ };
236
+ return $el("div", {textContent: item.prompt[0] + ": "}, [
237
+ $el("button", {
238
+ textContent: "Load",
239
+ onclick: async () => {
240
+ await app.loadGraphData(item.prompt[3].extra_pnginfo.workflow, true, false);
241
+ if (item.outputs) {
242
+ app.nodeOutputs = item.outputs;
243
+ }
244
+ },
245
+ }),
246
+ $el("button", {
247
+ textContent: removeAction.name,
248
+ onclick: async () => {
249
+ await removeAction.cb();
250
+ await this.update();
251
+ },
252
+ }),
253
+ ]);
254
+ }),
255
+ ]),
256
+ ]),
257
+ $el("div.comfy-list-actions", [
258
+ $el("button", {
259
+ textContent: "Clear " + this.#text,
260
+ onclick: async () => {
261
+ await api.clearItems(this.#type);
262
+ await this.load();
263
+ },
264
+ }),
265
+ $el("button", {textContent: "Refresh", onclick: () => this.load()}),
266
+ ])
267
+ );
268
+ }
269
+
270
+ async update() {
271
+ if (this.visible) {
272
+ await this.load();
273
+ }
274
+ }
275
+
276
+ async show() {
277
+ this.element.style.display = "block";
278
+ this.button.textContent = "Close";
279
+
280
+ await this.load();
281
+ }
282
+
283
+ hide() {
284
+ this.element.style.display = "none";
285
+ this.button.textContent = "View " + this.#text;
286
+ }
287
+
288
+ toggle() {
289
+ if (this.visible) {
290
+ this.hide();
291
+ return false;
292
+ } else {
293
+ this.show();
294
+ return true;
295
+ }
296
+ }
297
+ }
298
+
299
+ export class ComfyUI {
300
+ constructor(app) {
301
+ this.app = app;
302
+ this.dialog = new ComfyDialog();
303
+ this.settings = new ComfySettingsDialog(app);
304
+
305
+ this.batchCount = 1;
306
+ this.lastQueueSize = 0;
307
+ this.queue = new ComfyList("Queue");
308
+ this.history = new ComfyList("History", "history", true);
309
+
310
+ api.addEventListener("status", () => {
311
+ this.queue.update();
312
+ this.history.update();
313
+ });
314
+
315
+ const confirmClear = this.settings.addSetting({
316
+ id: "Comfy.ConfirmClear",
317
+ name: "Require confirmation when clearing workflow",
318
+ type: "boolean",
319
+ defaultValue: true,
320
+ });
321
+
322
+ const promptFilename = this.settings.addSetting({
323
+ id: "Comfy.PromptFilename",
324
+ name: "Prompt for filename when saving workflow",
325
+ type: "boolean",
326
+ defaultValue: true,
327
+ });
328
+
329
+ /**
330
+ * file format for preview
331
+ *
332
+ * format;quality
333
+ *
334
+ * ex)
335
+ * webp;50 -> webp, quality 50
336
+ * jpeg;80 -> rgb, jpeg, quality 80
337
+ *
338
+ * @type {string}
339
+ */
340
+ const previewImage = this.settings.addSetting({
341
+ id: "Comfy.PreviewFormat",
342
+ name: "When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.",
343
+ type: "text",
344
+ defaultValue: "",
345
+ });
346
+
347
+ this.settings.addSetting({
348
+ id: "Comfy.DisableSliders",
349
+ name: "Disable sliders.",
350
+ type: "boolean",
351
+ defaultValue: false,
352
+ });
353
+
354
+ this.settings.addSetting({
355
+ id: "Comfy.DisableFloatRounding",
356
+ name: "Disable rounding floats (requires page reload).",
357
+ type: "boolean",
358
+ defaultValue: false,
359
+ });
360
+
361
+ this.settings.addSetting({
362
+ id: "Comfy.FloatRoundingPrecision",
363
+ name: "Decimal places [0 = auto] (requires page reload).",
364
+ type: "slider",
365
+ attrs: {
366
+ min: 0,
367
+ max: 6,
368
+ step: 1,
369
+ },
370
+ defaultValue: 0,
371
+ });
372
+
373
+ const fileInput = $el("input", {
374
+ id: "comfy-file-input",
375
+ type: "file",
376
+ accept: ".json,image/png,.latent,.safetensors,image/webp,audio/flac",
377
+ style: {display: "none"},
378
+ parent: document.body,
379
+ onchange: () => {
380
+ app.handleFile(fileInput.files[0]);
381
+ },
382
+ });
383
+
384
+ this.loadFile = () => fileInput.click();
385
+
386
+ const autoQueueModeEl = toggleSwitch(
387
+ "autoQueueMode",
388
+ [
389
+ { text: "instant", tooltip: "A new prompt will be queued as soon as the queue reaches 0" },
390
+ { text: "change", tooltip: "A new prompt will be queued when the queue is at 0 and the graph is/has changed" },
391
+ ],
392
+ {
393
+ onChange: (value) => {
394
+ this.autoQueueMode = value.item.value;
395
+ },
396
+ }
397
+ );
398
+ autoQueueModeEl.style.display = "none";
399
+
400
+ api.addEventListener("graphChanged", () => {
401
+ if (this.autoQueueMode === "change" && this.autoQueueEnabled === true) {
402
+ if (this.lastQueueSize === 0) {
403
+ this.graphHasChanged = false;
404
+ app.queuePrompt(0, this.batchCount);
405
+ } else {
406
+ this.graphHasChanged = true;
407
+ }
408
+ }
409
+ });
410
+
411
+ this.menuHamburger = $el(
412
+ "div.comfy-menu-hamburger",
413
+ {
414
+ parent: document.body,
415
+ onclick: () => {
416
+ this.menuContainer.style.display = "block";
417
+ this.menuHamburger.style.display = "none";
418
+ },
419
+ },
420
+ [$el("div"), $el("div"), $el("div")]
421
+ );
422
+
423
+ this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [
424
+ $el("div.drag-handle.comfy-menu-header", {
425
+ style: {
426
+ overflow: "hidden",
427
+ position: "relative",
428
+ width: "100%",
429
+ cursor: "default"
430
+ }
431
+ }, [
432
+ $el("span.drag-handle"),
433
+ $el("span.comfy-menu-queue-size", { $: (q) => (this.queueSize = q) }),
434
+ $el("div.comfy-menu-actions", [
435
+ $el("button.comfy-settings-btn", {
436
+ textContent: "⚙️",
437
+ onclick: () => this.settings.show(),
438
+ }),
439
+ $el("button.comfy-close-menu-btn", {
440
+ textContent: "\u00d7",
441
+ onclick: () => {
442
+ this.menuContainer.style.display = "none";
443
+ this.menuHamburger.style.display = "flex";
444
+ },
445
+ }),
446
+ ]),
447
+ ]),
448
+ $el("button.comfy-queue-btn", {
449
+ id: "queue-button",
450
+ textContent: "Queue Prompt",
451
+ onclick: () => app.queuePrompt(0, this.batchCount),
452
+ }),
453
+ $el("div", {}, [
454
+ $el("label", {innerHTML: "Extra options"}, [
455
+ $el("input", {
456
+ type: "checkbox",
457
+ onchange: (i) => {
458
+ document.getElementById("extraOptions").style.display = i.srcElement.checked ? "block" : "none";
459
+ this.batchCount = i.srcElement.checked ? document.getElementById("batchCountInputRange").value : 1;
460
+ document.getElementById("autoQueueCheckbox").checked = false;
461
+ this.autoQueueEnabled = false;
462
+ },
463
+ }),
464
+ ]),
465
+ ]),
466
+ $el("div", {id: "extraOptions", style: {width: "100%", display: "none"}}, [
467
+ $el("div",[
468
+
469
+ $el("label", {innerHTML: "Batch count"}),
470
+ $el("input", {
471
+ id: "batchCountInputNumber",
472
+ type: "number",
473
+ value: this.batchCount,
474
+ min: "1",
475
+ style: {width: "35%", "margin-left": "0.4em"},
476
+ oninput: (i) => {
477
+ this.batchCount = i.target.value;
478
+ document.getElementById("batchCountInputRange").value = this.batchCount;
479
+ },
480
+ }),
481
+ $el("input", {
482
+ id: "batchCountInputRange",
483
+ type: "range",
484
+ min: "1",
485
+ max: "100",
486
+ value: this.batchCount,
487
+ oninput: (i) => {
488
+ this.batchCount = i.srcElement.value;
489
+ document.getElementById("batchCountInputNumber").value = i.srcElement.value;
490
+ },
491
+ }),
492
+ ]),
493
+ $el("div",[
494
+ $el("label",{
495
+ for:"autoQueueCheckbox",
496
+ innerHTML: "Auto Queue"
497
+ }),
498
+ $el("input", {
499
+ id: "autoQueueCheckbox",
500
+ type: "checkbox",
501
+ checked: false,
502
+ title: "Automatically queue prompt when the queue size hits 0",
503
+ onchange: (e) => {
504
+ this.autoQueueEnabled = e.target.checked;
505
+ autoQueueModeEl.style.display = this.autoQueueEnabled ? "" : "none";
506
+ }
507
+ }),
508
+ autoQueueModeEl
509
+ ])
510
+ ]),
511
+ $el("div.comfy-menu-btns", [
512
+ $el("button", {
513
+ id: "queue-front-button",
514
+ textContent: "Queue Front",
515
+ onclick: () => app.queuePrompt(-1, this.batchCount)
516
+ }),
517
+ $el("button", {
518
+ $: (b) => (this.queue.button = b),
519
+ id: "comfy-view-queue-button",
520
+ textContent: "View Queue",
521
+ onclick: () => {
522
+ this.history.hide();
523
+ this.queue.toggle();
524
+ },
525
+ }),
526
+ $el("button", {
527
+ $: (b) => (this.history.button = b),
528
+ id: "comfy-view-history-button",
529
+ textContent: "View History",
530
+ onclick: () => {
531
+ this.queue.hide();
532
+ this.history.toggle();
533
+ },
534
+ }),
535
+ ]),
536
+ this.queue.element,
537
+ this.history.element,
538
+ $el("button", {
539
+ id: "comfy-save-button",
540
+ textContent: "Save",
541
+ onclick: () => {
542
+ let filename = "workflow.json";
543
+ if (promptFilename.value) {
544
+ filename = prompt("Save workflow as:", filename);
545
+ if (!filename) return;
546
+ if (!filename.toLowerCase().endsWith(".json")) {
547
+ filename += ".json";
548
+ }
549
+ }
550
+ app.graphToPrompt().then(p=>{
551
+ const json = JSON.stringify(p.workflow, null, 2); // convert the data to a JSON string
552
+ const blob = new Blob([json], {type: "application/json"});
553
+ const url = URL.createObjectURL(blob);
554
+ const a = $el("a", {
555
+ href: url,
556
+ download: filename,
557
+ style: {display: "none"},
558
+ parent: document.body,
559
+ });
560
+ a.click();
561
+ setTimeout(function () {
562
+ a.remove();
563
+ window.URL.revokeObjectURL(url);
564
+ }, 0);
565
+ });
566
+ },
567
+ }),
568
+ $el("button", {
569
+ id: "comfy-dev-save-api-button",
570
+ textContent: "Save (API Format)",
571
+ style: {width: "100%", display: "none"},
572
+ onclick: () => {
573
+ let filename = "workflow_api.json";
574
+ if (promptFilename.value) {
575
+ filename = prompt("Save workflow (API) as:", filename);
576
+ if (!filename) return;
577
+ if (!filename.toLowerCase().endsWith(".json")) {
578
+ filename += ".json";
579
+ }
580
+ }
581
+ app.graphToPrompt().then(p=>{
582
+ const json = JSON.stringify(p.output, null, 2); // convert the data to a JSON string
583
+ const blob = new Blob([json], {type: "application/json"});
584
+ const url = URL.createObjectURL(blob);
585
+ const a = $el("a", {
586
+ href: url,
587
+ download: filename,
588
+ style: {display: "none"},
589
+ parent: document.body,
590
+ });
591
+ a.click();
592
+ setTimeout(function () {
593
+ a.remove();
594
+ window.URL.revokeObjectURL(url);
595
+ }, 0);
596
+ });
597
+ },
598
+ }),
599
+ $el("button", {id: "comfy-load-button", textContent: "Load", onclick: () => fileInput.click()}),
600
+ $el("button", {
601
+ id: "comfy-refresh-button",
602
+ textContent: "Refresh",
603
+ onclick: () => app.refreshComboInNodes()
604
+ }),
605
+ $el("button", {id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace()}),
606
+ $el("button", {
607
+ id: "comfy-clear-button", textContent: "Clear", onclick: () => {
608
+ if (!confirmClear.value || confirm("Clear workflow?")) {
609
+ app.clean();
610
+ app.graph.clear();
611
+ app.resetView();
612
+ }
613
+ }
614
+ }),
615
+ $el("button", {
616
+ id: "comfy-load-default-button", textContent: "Load Default", onclick: async () => {
617
+ if (!confirmClear.value || confirm("Load default workflow?")) {
618
+ app.resetView();
619
+ await app.loadGraphData()
620
+ }
621
+ }
622
+ }),
623
+ $el("button", {
624
+ id: "comfy-reset-view-button", textContent: "Reset View", onclick: async () => {
625
+ app.resetView();
626
+ }
627
+ }),
628
+ ]);
629
+
630
+ const devMode = this.settings.addSetting({
631
+ id: "Comfy.DevMode",
632
+ name: "Enable Dev mode Options",
633
+ type: "boolean",
634
+ defaultValue: false,
635
+ onChange: function(value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "flex" : "none"},
636
+ });
637
+
638
+ this.restoreMenuPosition = dragElement(this.menuContainer, this.settings);
639
+
640
+ this.setStatus({exec_info: {queue_remaining: "X"}});
641
+ }
642
+
643
+ setStatus(status) {
644
+ this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR");
645
+ if (status) {
646
+ if (
647
+ this.lastQueueSize != 0 &&
648
+ status.exec_info.queue_remaining == 0 &&
649
+ this.autoQueueEnabled &&
650
+ (this.autoQueueMode === "instant" || this.graphHasChanged) &&
651
+ !app.lastExecutionError
652
+ ) {
653
+ app.queuePrompt(0, this.batchCount);
654
+ status.exec_info.queue_remaining += this.batchCount;
655
+ this.graphHasChanged = false;
656
+ }
657
+ this.lastQueueSize = status.exec_info.queue_remaining;
658
+ }
659
+ }
660
+ }
ComfyUI/web/scripts/ui/components/asyncDialog.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComfyDialog } from "../dialog.js";
2
+ import { $el } from "../../ui.js";
3
+
4
+ export class ComfyAsyncDialog extends ComfyDialog {
5
+ #resolve;
6
+
7
+ constructor(actions) {
8
+ super(
9
+ "dialog.comfy-dialog.comfyui-dialog",
10
+ actions?.map((opt) => {
11
+ if (typeof opt === "string") {
12
+ opt = { text: opt };
13
+ }
14
+ return $el("button.comfyui-button", {
15
+ type: "button",
16
+ textContent: opt.text,
17
+ onclick: () => this.close(opt.value ?? opt.text),
18
+ });
19
+ })
20
+ );
21
+ }
22
+
23
+ show(html) {
24
+ this.element.addEventListener("close", () => {
25
+ this.close();
26
+ });
27
+
28
+ super.show(html);
29
+
30
+ return new Promise((resolve) => {
31
+ this.#resolve = resolve;
32
+ });
33
+ }
34
+
35
+ showModal(html) {
36
+ this.element.addEventListener("close", () => {
37
+ this.close();
38
+ });
39
+
40
+ super.show(html);
41
+ this.element.showModal();
42
+
43
+ return new Promise((resolve) => {
44
+ this.#resolve = resolve;
45
+ });
46
+ }
47
+
48
+ close(result = null) {
49
+ this.#resolve(result);
50
+ this.element.close();
51
+ super.close();
52
+ }
53
+
54
+ static async prompt({ title = null, message, actions }) {
55
+ const dialog = new ComfyAsyncDialog(actions);
56
+ const content = [$el("span", message)];
57
+ if (title) {
58
+ content.unshift($el("h3", title));
59
+ }
60
+ const res = await dialog.showModal(content);
61
+ dialog.element.remove();
62
+ return res;
63
+ }
64
+ }
ComfyUI/web/scripts/ui/components/button.js ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { $el } from "../../ui.js";
4
+ import { applyClasses, toggleElement } from "../utils.js";
5
+ import { prop } from "../../utils.js";
6
+
7
+ /**
8
+ * @typedef {{
9
+ * icon?: string;
10
+ * overIcon?: string;
11
+ * iconSize?: number;
12
+ * content?: string | HTMLElement;
13
+ * tooltip?: string;
14
+ * enabled?: boolean;
15
+ * action?: (e: Event, btn: ComfyButton) => void,
16
+ * classList?: import("../utils.js").ClassList,
17
+ * visibilitySetting?: { id: string, showValue: any },
18
+ * app?: import("../../app.js").ComfyApp
19
+ * }} ComfyButtonProps
20
+ */
21
+ export class ComfyButton {
22
+ #over = 0;
23
+ #popupOpen = false;
24
+ isOver = false;
25
+ iconElement = $el("i.mdi");
26
+ contentElement = $el("span");
27
+ /**
28
+ * @type {import("./popup.js").ComfyPopup}
29
+ */
30
+ popup;
31
+
32
+ /**
33
+ * @param {ComfyButtonProps} opts
34
+ */
35
+ constructor({
36
+ icon,
37
+ overIcon,
38
+ iconSize,
39
+ content,
40
+ tooltip,
41
+ action,
42
+ classList = "comfyui-button",
43
+ visibilitySetting,
44
+ app,
45
+ enabled = true,
46
+ }) {
47
+ this.element = $el("button", {
48
+ onmouseenter: () => {
49
+ this.isOver = true;
50
+ if(this.overIcon) {
51
+ this.updateIcon();
52
+ }
53
+ },
54
+ onmouseleave: () => {
55
+ this.isOver = false;
56
+ if(this.overIcon) {
57
+ this.updateIcon();
58
+ }
59
+ }
60
+
61
+ }, [this.iconElement, this.contentElement]);
62
+
63
+ this.icon = prop(this, "icon", icon, toggleElement(this.iconElement, { onShow: this.updateIcon }));
64
+ this.overIcon = prop(this, "overIcon", overIcon, () => {
65
+ if(this.isOver) {
66
+ this.updateIcon();
67
+ }
68
+ });
69
+ this.iconSize = prop(this, "iconSize", iconSize, this.updateIcon);
70
+ this.content = prop(
71
+ this,
72
+ "content",
73
+ content,
74
+ toggleElement(this.contentElement, {
75
+ onShow: (el, v) => {
76
+ if (typeof v === "string") {
77
+ el.textContent = v;
78
+ } else {
79
+ el.replaceChildren(v);
80
+ }
81
+ },
82
+ })
83
+ );
84
+
85
+ this.tooltip = prop(this, "tooltip", tooltip, (v) => {
86
+ if (v) {
87
+ this.element.title = v;
88
+ } else {
89
+ this.element.removeAttribute("title");
90
+ }
91
+ });
92
+ this.classList = prop(this, "classList", classList, this.updateClasses);
93
+ this.hidden = prop(this, "hidden", false, this.updateClasses);
94
+ this.enabled = prop(this, "enabled", enabled, () => {
95
+ this.updateClasses();
96
+ this.element.disabled = !this.enabled;
97
+ });
98
+ this.action = prop(this, "action", action);
99
+ this.element.addEventListener("click", (e) => {
100
+ if (this.popup) {
101
+ // we are either a touch device or triggered by click not hover
102
+ if (!this.#over) {
103
+ this.popup.toggle();
104
+ }
105
+ }
106
+ this.action?.(e, this);
107
+ });
108
+
109
+ if (visibilitySetting?.id) {
110
+ const settingUpdated = () => {
111
+ this.hidden = app.ui.settings.getSettingValue(visibilitySetting.id) !== visibilitySetting.showValue;
112
+ };
113
+ app.ui.settings.addEventListener(visibilitySetting.id + ".change", settingUpdated);
114
+ settingUpdated();
115
+ }
116
+ }
117
+
118
+ updateIcon = () => (this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? " mdi-" + this.iconSize + "px" : ""}`);
119
+ updateClasses = () => {
120
+ const internalClasses = [];
121
+ if (this.hidden) {
122
+ internalClasses.push("hidden");
123
+ }
124
+ if (!this.enabled) {
125
+ internalClasses.push("disabled");
126
+ }
127
+ if (this.popup) {
128
+ if (this.#popupOpen) {
129
+ internalClasses.push("popup-open");
130
+ } else {
131
+ internalClasses.push("popup-closed");
132
+ }
133
+ }
134
+ applyClasses(this.element, this.classList, ...internalClasses);
135
+ };
136
+
137
+ /**
138
+ *
139
+ * @param { import("./popup.js").ComfyPopup } popup
140
+ * @param { "click" | "hover" } mode
141
+ */
142
+ withPopup(popup, mode = "click") {
143
+ this.popup = popup;
144
+
145
+ if (mode === "hover") {
146
+ for (const el of [this.element, this.popup.element]) {
147
+ el.addEventListener("mouseenter", () => {
148
+ this.popup.open = !!++this.#over;
149
+ });
150
+ el.addEventListener("mouseleave", () => {
151
+ this.popup.open = !!--this.#over;
152
+ });
153
+ }
154
+ }
155
+
156
+ popup.addEventListener("change", () => {
157
+ this.#popupOpen = popup.open;
158
+ this.updateClasses();
159
+ });
160
+
161
+ return this;
162
+ }
163
+ }
ComfyUI/web/scripts/ui/components/buttonGroup.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { $el } from "../../ui.js";
4
+ import { ComfyButton } from "./button.js";
5
+ import { prop } from "../../utils.js";
6
+
7
+ export class ComfyButtonGroup {
8
+ element = $el("div.comfyui-button-group");
9
+
10
+ /** @param {Array<ComfyButton | HTMLElement>} buttons */
11
+ constructor(...buttons) {
12
+ this.buttons = prop(this, "buttons", buttons, () => this.update());
13
+ }
14
+
15
+ /**
16
+ * @param {ComfyButton} button
17
+ * @param {number} index
18
+ */
19
+ insert(button, index) {
20
+ this.buttons.splice(index, 0, button);
21
+ this.update();
22
+ }
23
+
24
+ /** @param {ComfyButton} button */
25
+ append(button) {
26
+ this.buttons.push(button);
27
+ this.update();
28
+ }
29
+
30
+ /** @param {ComfyButton|number} indexOrButton */
31
+ remove(indexOrButton) {
32
+ if (typeof indexOrButton !== "number") {
33
+ indexOrButton = this.buttons.indexOf(indexOrButton);
34
+ }
35
+ if (indexOrButton > -1) {
36
+ const r = this.buttons.splice(indexOrButton, 1);
37
+ this.update();
38
+ return r;
39
+ }
40
+ }
41
+
42
+ update() {
43
+ this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
44
+ }
45
+ }
ComfyUI/web/scripts/ui/components/popup.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { prop } from "../../utils.js";
4
+ import { $el } from "../../ui.js";
5
+ import { applyClasses } from "../utils.js";
6
+
7
+ export class ComfyPopup extends EventTarget {
8
+ element = $el("div.comfyui-popup");
9
+
10
+ /**
11
+ * @param {{
12
+ * target: HTMLElement,
13
+ * container?: HTMLElement,
14
+ * classList?: import("../utils.js").ClassList,
15
+ * ignoreTarget?: boolean,
16
+ * closeOnEscape?: boolean,
17
+ * position?: "absolute" | "relative",
18
+ * horizontal?: "left" | "right"
19
+ * }} param0
20
+ * @param {...HTMLElement} children
21
+ */
22
+ constructor(
23
+ {
24
+ target,
25
+ container = document.body,
26
+ classList = "",
27
+ ignoreTarget = true,
28
+ closeOnEscape = true,
29
+ position = "absolute",
30
+ horizontal = "left",
31
+ },
32
+ ...children
33
+ ) {
34
+ super();
35
+ this.target = target;
36
+ this.ignoreTarget = ignoreTarget;
37
+ this.container = container;
38
+ this.position = position;
39
+ this.closeOnEscape = closeOnEscape;
40
+ this.horizontal = horizontal;
41
+
42
+ container.append(this.element);
43
+
44
+ this.children = prop(this, "children", children, () => {
45
+ this.element.replaceChildren(...this.children);
46
+ this.update();
47
+ });
48
+ this.classList = prop(this, "classList", classList, () => applyClasses(this.element, this.classList, "comfyui-popup", horizontal));
49
+ this.open = prop(this, "open", false, (v, o) => {
50
+ if (v === o) return;
51
+ if (v) {
52
+ this.#show();
53
+ } else {
54
+ this.#hide();
55
+ }
56
+ });
57
+ }
58
+
59
+ toggle() {
60
+ this.open = !this.open;
61
+ }
62
+
63
+ #hide() {
64
+ this.element.classList.remove("open");
65
+ window.removeEventListener("resize", this.update);
66
+ window.removeEventListener("click", this.#clickHandler, { capture: true });
67
+ window.removeEventListener("keydown", this.#escHandler, { capture: true });
68
+
69
+ this.dispatchEvent(new CustomEvent("close"));
70
+ this.dispatchEvent(new CustomEvent("change"));
71
+ }
72
+
73
+ #show() {
74
+ this.element.classList.add("open");
75
+ this.update();
76
+
77
+ window.addEventListener("resize", this.update);
78
+ window.addEventListener("click", this.#clickHandler, { capture: true });
79
+ if (this.closeOnEscape) {
80
+ window.addEventListener("keydown", this.#escHandler, { capture: true });
81
+ }
82
+
83
+ this.dispatchEvent(new CustomEvent("open"));
84
+ this.dispatchEvent(new CustomEvent("change"));
85
+ }
86
+
87
+ #escHandler = (e) => {
88
+ if (e.key === "Escape") {
89
+ this.open = false;
90
+ e.preventDefault();
91
+ e.stopImmediatePropagation();
92
+ }
93
+ };
94
+
95
+ #clickHandler = (e) => {
96
+ /** @type {any} */
97
+ const target = e.target;
98
+ if (!this.element.contains(target) && this.ignoreTarget && !this.target.contains(target)) {
99
+ this.open = false;
100
+ }
101
+ };
102
+
103
+ update = () => {
104
+ const rect = this.target.getBoundingClientRect();
105
+ this.element.style.setProperty("--bottom", "unset");
106
+ if (this.position === "absolute") {
107
+ if (this.horizontal === "left") {
108
+ this.element.style.setProperty("--left", rect.left + "px");
109
+ } else {
110
+ this.element.style.setProperty("--left", rect.right - this.element.clientWidth + "px");
111
+ }
112
+ this.element.style.setProperty("--top", rect.bottom + "px");
113
+ this.element.style.setProperty("--limit", rect.bottom + "px");
114
+ } else {
115
+ this.element.style.setProperty("--left", 0 + "px");
116
+ this.element.style.setProperty("--top", rect.height + "px");
117
+ this.element.style.setProperty("--limit", rect.height + "px");
118
+ }
119
+
120
+ const thisRect = this.element.getBoundingClientRect();
121
+ if (thisRect.height < 30) {
122
+ // Move up instead
123
+ this.element.style.setProperty("--top", "unset");
124
+ this.element.style.setProperty("--bottom", rect.height + 5 + "px");
125
+ this.element.style.setProperty("--limit", rect.height + 5 + "px");
126
+ }
127
+ };
128
+ }
ComfyUI/web/scripts/ui/components/splitButton.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { $el } from "../../ui.js";
4
+ import { ComfyButton } from "./button.js";
5
+ import { prop } from "../../utils.js";
6
+ import { ComfyPopup } from "./popup.js";
7
+
8
+ export class ComfySplitButton {
9
+ /**
10
+ * @param {{
11
+ * primary: ComfyButton,
12
+ * mode?: "hover" | "click",
13
+ * horizontal?: "left" | "right",
14
+ * position?: "relative" | "absolute"
15
+ * }} param0
16
+ * @param {Array<ComfyButton> | Array<HTMLElement>} items
17
+ */
18
+ constructor({ primary, mode, horizontal = "left", position = "relative" }, ...items) {
19
+ this.arrow = new ComfyButton({
20
+ icon: "chevron-down",
21
+ });
22
+ this.element = $el("div.comfyui-split-button" + (mode === "hover" ? ".hover" : ""), [
23
+ $el("div.comfyui-split-primary", primary.element),
24
+ $el("div.comfyui-split-arrow", this.arrow.element),
25
+ ]);
26
+ this.popup = new ComfyPopup({
27
+ target: this.element,
28
+ container: position === "relative" ? this.element : document.body,
29
+ classList: "comfyui-split-button-popup" + (mode === "hover" ? " hover" : ""),
30
+ closeOnEscape: mode === "click",
31
+ position,
32
+ horizontal,
33
+ });
34
+
35
+ this.arrow.withPopup(this.popup, mode);
36
+
37
+ this.items = prop(this, "items", items, () => this.update());
38
+ }
39
+
40
+ update() {
41
+ this.popup.element.replaceChildren(...this.items.map((b) => b.element ?? b));
42
+ }
43
+ }
ComfyUI/web/scripts/ui/dialog.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el } from "../ui.js";
2
+
3
+ export class ComfyDialog extends EventTarget {
4
+ #buttons;
5
+
6
+ constructor(type = "div", buttons = null) {
7
+ super();
8
+ this.#buttons = buttons;
9
+ this.element = $el(type + ".comfy-modal", { parent: document.body }, [
10
+ $el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
11
+ ]);
12
+ }
13
+
14
+ createButtons() {
15
+ return (
16
+ this.#buttons ?? [
17
+ $el("button", {
18
+ type: "button",
19
+ textContent: "Close",
20
+ onclick: () => this.close(),
21
+ }),
22
+ ]
23
+ );
24
+ }
25
+
26
+ close() {
27
+ this.element.style.display = "none";
28
+ }
29
+
30
+ show(html) {
31
+ if (typeof html === "string") {
32
+ this.textElement.innerHTML = html;
33
+ } else {
34
+ this.textElement.replaceChildren(...(html instanceof Array ? html : [html]));
35
+ }
36
+ this.element.style.display = "flex";
37
+ }
38
+ }
ComfyUI/web/scripts/ui/draggableList.js ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /*
3
+ Original implementation:
4
+ https://github.com/TahaSh/drag-to-reorder
5
+ MIT License
6
+
7
+ Copyright (c) 2023 Taha Shashtari
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ */
27
+
28
+ import { $el } from "../ui.js";
29
+
30
+ $el("style", {
31
+ parent: document.head,
32
+ textContent: `
33
+ .draggable-item {
34
+ position: relative;
35
+ will-change: transform;
36
+ user-select: none;
37
+ }
38
+ .draggable-item.is-idle {
39
+ transition: 0.25s ease transform;
40
+ }
41
+ .draggable-item.is-draggable {
42
+ z-index: 10;
43
+ }
44
+ `
45
+ });
46
+
47
+ export class DraggableList extends EventTarget {
48
+ listContainer;
49
+ draggableItem;
50
+ pointerStartX;
51
+ pointerStartY;
52
+ scrollYMax;
53
+ itemsGap = 0;
54
+ items = [];
55
+ itemSelector;
56
+ handleClass = "drag-handle";
57
+ off = [];
58
+ offDrag = [];
59
+
60
+ constructor(element, itemSelector) {
61
+ super();
62
+ this.listContainer = element;
63
+ this.itemSelector = itemSelector;
64
+
65
+ if (!this.listContainer) return;
66
+
67
+ this.off.push(this.on(this.listContainer, "mousedown", this.dragStart));
68
+ this.off.push(this.on(this.listContainer, "touchstart", this.dragStart));
69
+ this.off.push(this.on(document, "mouseup", this.dragEnd));
70
+ this.off.push(this.on(document, "touchend", this.dragEnd));
71
+ }
72
+
73
+ getAllItems() {
74
+ if (!this.items?.length) {
75
+ this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector));
76
+ this.items.forEach((element) => {
77
+ element.classList.add("is-idle");
78
+ });
79
+ }
80
+ return this.items;
81
+ }
82
+
83
+ getIdleItems() {
84
+ return this.getAllItems().filter((item) => item.classList.contains("is-idle"));
85
+ }
86
+
87
+ isItemAbove(item) {
88
+ return item.hasAttribute("data-is-above");
89
+ }
90
+
91
+ isItemToggled(item) {
92
+ return item.hasAttribute("data-is-toggled");
93
+ }
94
+
95
+ on(source, event, listener, options) {
96
+ listener = listener.bind(this);
97
+ source.addEventListener(event, listener, options);
98
+ return () => source.removeEventListener(event, listener);
99
+ }
100
+
101
+ dragStart(e) {
102
+ if (e.target.classList.contains(this.handleClass)) {
103
+ this.draggableItem = e.target.closest(this.itemSelector);
104
+ }
105
+
106
+ if (!this.draggableItem) return;
107
+
108
+ this.pointerStartX = e.clientX || e.touches[0].clientX;
109
+ this.pointerStartY = e.clientY || e.touches[0].clientY;
110
+ this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight;
111
+
112
+ this.setItemsGap();
113
+ this.initDraggableItem();
114
+ this.initItemsState();
115
+
116
+ this.offDrag.push(this.on(document, "mousemove", this.drag));
117
+ this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false }));
118
+
119
+ this.dispatchEvent(
120
+ new CustomEvent("dragstart", {
121
+ detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) },
122
+ })
123
+ );
124
+ }
125
+
126
+ setItemsGap() {
127
+ if (this.getIdleItems().length <= 1) {
128
+ this.itemsGap = 0;
129
+ return;
130
+ }
131
+
132
+ const item1 = this.getIdleItems()[0];
133
+ const item2 = this.getIdleItems()[1];
134
+
135
+ const item1Rect = item1.getBoundingClientRect();
136
+ const item2Rect = item2.getBoundingClientRect();
137
+
138
+ this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
139
+ }
140
+
141
+ initItemsState() {
142
+ this.getIdleItems().forEach((item, i) => {
143
+ if (this.getAllItems().indexOf(this.draggableItem) > i) {
144
+ item.dataset.isAbove = "";
145
+ }
146
+ });
147
+ }
148
+
149
+ initDraggableItem() {
150
+ this.draggableItem.classList.remove("is-idle");
151
+ this.draggableItem.classList.add("is-draggable");
152
+ }
153
+
154
+ drag(e) {
155
+ if (!this.draggableItem) return;
156
+
157
+ e.preventDefault();
158
+
159
+ const clientX = e.clientX || e.touches[0].clientX;
160
+ const clientY = e.clientY || e.touches[0].clientY;
161
+
162
+ const listRect = this.listContainer.getBoundingClientRect();
163
+
164
+ if (clientY > listRect.bottom) {
165
+ if (this.listContainer.scrollTop < this.scrollYMax) {
166
+ this.listContainer.scrollBy(0, 10);
167
+ this.pointerStartY -= 10;
168
+ }
169
+ } else if (clientY < listRect.top && this.listContainer.scrollTop > 0) {
170
+ this.pointerStartY += 10;
171
+ this.listContainer.scrollBy(0, -10);
172
+ }
173
+
174
+ const pointerOffsetX = clientX - this.pointerStartX;
175
+ const pointerOffsetY = clientY - this.pointerStartY;
176
+
177
+ this.updateIdleItemsStateAndPosition();
178
+ this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
179
+ }
180
+
181
+ updateIdleItemsStateAndPosition() {
182
+ const draggableItemRect = this.draggableItem.getBoundingClientRect();
183
+ const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
184
+
185
+ // Update state
186
+ this.getIdleItems().forEach((item) => {
187
+ const itemRect = item.getBoundingClientRect();
188
+ const itemY = itemRect.top + itemRect.height / 2;
189
+ if (this.isItemAbove(item)) {
190
+ if (draggableItemY <= itemY) {
191
+ item.dataset.isToggled = "";
192
+ } else {
193
+ delete item.dataset.isToggled;
194
+ }
195
+ } else {
196
+ if (draggableItemY >= itemY) {
197
+ item.dataset.isToggled = "";
198
+ } else {
199
+ delete item.dataset.isToggled;
200
+ }
201
+ }
202
+ });
203
+
204
+ // Update position
205
+ this.getIdleItems().forEach((item) => {
206
+ if (this.isItemToggled(item)) {
207
+ const direction = this.isItemAbove(item) ? 1 : -1;
208
+ item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`;
209
+ } else {
210
+ item.style.transform = "";
211
+ }
212
+ });
213
+ }
214
+
215
+ dragEnd() {
216
+ if (!this.draggableItem) return;
217
+
218
+ this.applyNewItemsOrder();
219
+ this.cleanup();
220
+ }
221
+
222
+ applyNewItemsOrder() {
223
+ const reorderedItems = [];
224
+
225
+ let oldPosition = -1;
226
+ this.getAllItems().forEach((item, index) => {
227
+ if (item === this.draggableItem) {
228
+ oldPosition = index;
229
+ return;
230
+ }
231
+ if (!this.isItemToggled(item)) {
232
+ reorderedItems[index] = item;
233
+ return;
234
+ }
235
+ const newIndex = this.isItemAbove(item) ? index + 1 : index - 1;
236
+ reorderedItems[newIndex] = item;
237
+ });
238
+
239
+ for (let index = 0; index < this.getAllItems().length; index++) {
240
+ const item = reorderedItems[index];
241
+ if (typeof item === "undefined") {
242
+ reorderedItems[index] = this.draggableItem;
243
+ }
244
+ }
245
+
246
+ reorderedItems.forEach((item) => {
247
+ this.listContainer.appendChild(item);
248
+ });
249
+
250
+ this.items = reorderedItems;
251
+
252
+ this.dispatchEvent(
253
+ new CustomEvent("dragend", {
254
+ detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) },
255
+ })
256
+ );
257
+ }
258
+
259
+ cleanup() {
260
+ this.itemsGap = 0;
261
+ this.items = [];
262
+ this.unsetDraggableItem();
263
+ this.unsetItemState();
264
+
265
+ this.offDrag.forEach((f) => f());
266
+ this.offDrag = [];
267
+ }
268
+
269
+ unsetDraggableItem() {
270
+ this.draggableItem.style = null;
271
+ this.draggableItem.classList.remove("is-draggable");
272
+ this.draggableItem.classList.add("is-idle");
273
+ this.draggableItem = null;
274
+ }
275
+
276
+ unsetItemState() {
277
+ this.getIdleItems().forEach((item, i) => {
278
+ delete item.dataset.isAbove;
279
+ delete item.dataset.isToggled;
280
+ item.style.transform = "";
281
+ });
282
+ }
283
+
284
+ dispose() {
285
+ this.off.forEach((f) => f());
286
+ }
287
+ }
ComfyUI/web/scripts/ui/imagePreview.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el } from "../ui.js";
2
+
3
+ export function calculateImageGrid(imgs, dw, dh) {
4
+ let best = 0;
5
+ let w = imgs[0].naturalWidth;
6
+ let h = imgs[0].naturalHeight;
7
+ const numImages = imgs.length;
8
+
9
+ let cellWidth, cellHeight, cols, rows, shiftX;
10
+ // compact style
11
+ for (let c = 1; c <= numImages; c++) {
12
+ const r = Math.ceil(numImages / c);
13
+ const cW = dw / c;
14
+ const cH = dh / r;
15
+ const scaleX = cW / w;
16
+ const scaleY = cH / h;
17
+
18
+ const scale = Math.min(scaleX, scaleY, 1);
19
+ const imageW = w * scale;
20
+ const imageH = h * scale;
21
+ const area = imageW * imageH * numImages;
22
+
23
+ if (area > best) {
24
+ best = area;
25
+ cellWidth = imageW;
26
+ cellHeight = imageH;
27
+ cols = c;
28
+ rows = r;
29
+ shiftX = c * ((cW - imageW) / 2);
30
+ }
31
+ }
32
+
33
+ return { cellWidth, cellHeight, cols, rows, shiftX };
34
+ }
35
+
36
+ export function createImageHost(node) {
37
+ const el = $el("div.comfy-img-preview");
38
+ let currentImgs;
39
+ let first = true;
40
+
41
+ function updateSize() {
42
+ let w = null;
43
+ let h = null;
44
+
45
+ if (currentImgs) {
46
+ let elH = el.clientHeight;
47
+ if (first) {
48
+ first = false;
49
+ // On first run, if we are small then grow a bit
50
+ if (elH < 190) {
51
+ elH = 190;
52
+ }
53
+ el.style.setProperty("--comfy-widget-min-height", elH);
54
+ } else {
55
+ el.style.setProperty("--comfy-widget-min-height", null);
56
+ }
57
+
58
+ const nw = node.size[0];
59
+ ({ cellWidth: w, cellHeight: h } = calculateImageGrid(currentImgs, nw - 20, elH));
60
+ w += "px";
61
+ h += "px";
62
+
63
+ el.style.setProperty("--comfy-img-preview-width", w);
64
+ el.style.setProperty("--comfy-img-preview-height", h);
65
+ }
66
+ }
67
+ return {
68
+ el,
69
+ updateImages(imgs) {
70
+ if (imgs !== currentImgs) {
71
+ if (currentImgs == null) {
72
+ requestAnimationFrame(() => {
73
+ updateSize();
74
+ });
75
+ }
76
+ el.replaceChildren(...imgs);
77
+ currentImgs = imgs;
78
+ node.onResize(node.size);
79
+ node.graph.setDirtyCanvas(true, true);
80
+ }
81
+ },
82
+ getHeight() {
83
+ updateSize();
84
+ },
85
+ onDraw() {
86
+ // Element from point uses a hittest find elements so we need to toggle pointer events
87
+ el.style.pointerEvents = "all";
88
+ const over = document.elementFromPoint(app.canvas.mouse[0], app.canvas.mouse[1]);
89
+ el.style.pointerEvents = "none";
90
+
91
+ if(!over) return;
92
+ // Set the overIndex so Open Image etc work
93
+ const idx = currentImgs.indexOf(over);
94
+ node.overIndex = idx;
95
+ },
96
+ };
97
+ }
ComfyUI/web/scripts/ui/menu/index.js ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { $el } from "../../ui.js";
4
+ import { downloadBlob } from "../../utils.js";
5
+ import { ComfyButton } from "../components/button.js";
6
+ import { ComfyButtonGroup } from "../components/buttonGroup.js";
7
+ import { ComfySplitButton } from "../components/splitButton.js";
8
+ import { ComfyViewHistoryButton } from "./viewHistory.js";
9
+ import { ComfyQueueButton } from "./queueButton.js";
10
+ import { ComfyWorkflowsMenu } from "./workflows.js";
11
+ import { ComfyViewQueueButton } from "./viewQueue.js";
12
+ import { getInteruptButton } from "./interruptButton.js";
13
+
14
+ const collapseOnMobile = (t) => {
15
+ (t.element ?? t).classList.add("comfyui-menu-mobile-collapse");
16
+ return t;
17
+ };
18
+ const showOnMobile = (t) => {
19
+ (t.element ?? t).classList.add("lt-lg-show");
20
+ return t;
21
+ };
22
+
23
+ export class ComfyAppMenu {
24
+ #sizeBreak = "lg";
25
+ #lastSizeBreaks = {
26
+ lg: null,
27
+ md: null,
28
+ sm: null,
29
+ xs: null,
30
+ };
31
+ #sizeBreaks = Object.keys(this.#lastSizeBreaks);
32
+ #cachedInnerSize = null;
33
+ #cacheTimeout = null;
34
+
35
+ /**
36
+ * @param { import("../../app.js").ComfyApp } app
37
+ */
38
+ constructor(app) {
39
+ this.app = app;
40
+
41
+ this.workflows = new ComfyWorkflowsMenu(app);
42
+ const getSaveButton = (t) =>
43
+ new ComfyButton({
44
+ icon: "content-save",
45
+ tooltip: "Save the current workflow",
46
+ action: () => app.workflowManager.activeWorkflow.save(),
47
+ content: t,
48
+ });
49
+
50
+ this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI");
51
+ this.saveButton = new ComfySplitButton(
52
+ {
53
+ primary: getSaveButton(),
54
+ mode: "hover",
55
+ position: "absolute",
56
+ },
57
+ getSaveButton("Save"),
58
+ new ComfyButton({
59
+ icon: "content-save-edit",
60
+ content: "Save As",
61
+ tooltip: "Save the current graph as a new workflow",
62
+ action: () => app.workflowManager.activeWorkflow.save(true),
63
+ }),
64
+ new ComfyButton({
65
+ icon: "download",
66
+ content: "Export",
67
+ tooltip: "Export the current workflow as JSON",
68
+ action: () => this.exportWorkflow("workflow", "workflow"),
69
+ }),
70
+ new ComfyButton({
71
+ icon: "api",
72
+ content: "Export (API Format)",
73
+ tooltip: "Export the current workflow as JSON for use with the ComfyUI API",
74
+ action: () => this.exportWorkflow("workflow_api", "output"),
75
+ visibilitySetting: { id: "Comfy.DevMode", showValue: true },
76
+ app,
77
+ })
78
+ );
79
+ this.actionsGroup = new ComfyButtonGroup(
80
+ new ComfyButton({
81
+ icon: "refresh",
82
+ content: "Refresh",
83
+ tooltip: "Refresh widgets in nodes to find new models or files",
84
+ action: () => app.refreshComboInNodes(),
85
+ }),
86
+ new ComfyButton({
87
+ icon: "clipboard-edit-outline",
88
+ content: "Clipspace",
89
+ tooltip: "Open Clipspace window",
90
+ action: () => app["openClipspace"](),
91
+ }),
92
+ new ComfyButton({
93
+ icon: "fit-to-page-outline",
94
+ content: "Reset View",
95
+ tooltip: "Reset the canvas view",
96
+ action: () => app.resetView(),
97
+ }),
98
+ new ComfyButton({
99
+ icon: "cancel",
100
+ content: "Clear",
101
+ tooltip: "Clears current workflow",
102
+ action: () => {
103
+ if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) {
104
+ app.clean();
105
+ app.graph.clear();
106
+ }
107
+ },
108
+ })
109
+ );
110
+ this.settingsGroup = new ComfyButtonGroup(
111
+ new ComfyButton({
112
+ icon: "cog",
113
+ content: "Settings",
114
+ tooltip: "Open settings",
115
+ action: () => {
116
+ app.ui.settings.show();
117
+ },
118
+ })
119
+ );
120
+ this.viewGroup = new ComfyButtonGroup(
121
+ new ComfyViewHistoryButton(app).element,
122
+ new ComfyViewQueueButton(app).element,
123
+ getInteruptButton("nlg-hide").element
124
+ );
125
+ this.mobileMenuButton = new ComfyButton({
126
+ icon: "menu",
127
+ action: (_, btn) => {
128
+ btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu";
129
+ window.dispatchEvent(new Event("resize"));
130
+ },
131
+ classList: "comfyui-button comfyui-menu-button",
132
+ });
133
+
134
+ this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [
135
+ this.logo,
136
+ this.workflows.element,
137
+ this.saveButton.element,
138
+ collapseOnMobile(this.actionsGroup).element,
139
+ $el("section.comfyui-menu-push"),
140
+ collapseOnMobile(this.settingsGroup).element,
141
+ collapseOnMobile(this.viewGroup).element,
142
+
143
+ getInteruptButton("lt-lg-show").element,
144
+ new ComfyQueueButton(app).element,
145
+ showOnMobile(this.mobileMenuButton).element,
146
+ ]);
147
+
148
+ let resizeHandler;
149
+ this.menuPositionSetting = app.ui.settings.addSetting({
150
+ id: "Comfy.UseNewMenu",
151
+ defaultValue: "Disabled",
152
+ name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.",
153
+ type: "combo",
154
+ options: ["Disabled", "Top", "Bottom"],
155
+ onChange: async (v) => {
156
+ if (v && v !== "Disabled") {
157
+ if (!resizeHandler) {
158
+ resizeHandler = () => {
159
+ this.calculateSizeBreak();
160
+ };
161
+ window.addEventListener("resize", resizeHandler);
162
+ }
163
+ this.updatePosition(v);
164
+ } else {
165
+ if (resizeHandler) {
166
+ window.removeEventListener("resize", resizeHandler);
167
+ resizeHandler = null;
168
+ }
169
+ document.body.style.removeProperty("display");
170
+ app.ui.menuContainer.style.removeProperty("display");
171
+ this.element.style.display = "none";
172
+ app.ui.restoreMenuPosition();
173
+ }
174
+ window.dispatchEvent(new Event("resize"));
175
+ },
176
+ });
177
+ }
178
+
179
+ updatePosition(v) {
180
+ document.body.style.display = "grid";
181
+ this.app.ui.menuContainer.style.display = "none";
182
+ this.element.style.removeProperty("display");
183
+ this.position = v;
184
+ if (v === "Bottom") {
185
+ this.app.bodyBottom.append(this.element);
186
+ } else {
187
+ this.app.bodyTop.prepend(this.element);
188
+ }
189
+ this.calculateSizeBreak();
190
+ }
191
+
192
+ updateSizeBreak(idx, prevIdx, direction) {
193
+ const newSize = this.#sizeBreaks[idx];
194
+ if (newSize === this.#sizeBreak) return;
195
+ this.#cachedInnerSize = null;
196
+ clearTimeout(this.#cacheTimeout);
197
+
198
+ this.#sizeBreak = this.#sizeBreaks[idx];
199
+ for (let i = 0; i < this.#sizeBreaks.length; i++) {
200
+ const sz = this.#sizeBreaks[i];
201
+ if (sz === this.#sizeBreak) {
202
+ this.element.classList.add(sz);
203
+ } else {
204
+ this.element.classList.remove(sz);
205
+ }
206
+ if (i < idx) {
207
+ this.element.classList.add("lt-" + sz);
208
+ } else {
209
+ this.element.classList.remove("lt-" + sz);
210
+ }
211
+ }
212
+
213
+ if (idx) {
214
+ // We're on a small screen, force the menu at the top
215
+ if (this.position !== "Top") {
216
+ this.updatePosition("Top");
217
+ }
218
+ } else if (this.position != this.menuPositionSetting.value) {
219
+ // Restore user position
220
+ this.updatePosition(this.menuPositionSetting.value);
221
+ }
222
+
223
+ // Allow multiple updates, but prevent bouncing
224
+ if (!direction) {
225
+ direction = prevIdx - idx;
226
+ } else if (direction != prevIdx - idx) {
227
+ return;
228
+ }
229
+ this.calculateSizeBreak(direction);
230
+ }
231
+
232
+ calculateSizeBreak(direction = 0) {
233
+ let idx = this.#sizeBreaks.indexOf(this.#sizeBreak);
234
+ const currIdx = idx;
235
+ const innerSize = this.calculateInnerSize(idx);
236
+ if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) {
237
+ if (idx > 0) {
238
+ idx--;
239
+ }
240
+ } else if (innerSize > this.element.clientWidth) {
241
+ this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize);
242
+ // We need to shrink
243
+ if (idx < this.#sizeBreaks.length - 1) {
244
+ idx++;
245
+ }
246
+ }
247
+
248
+ this.updateSizeBreak(idx, currIdx, direction);
249
+ }
250
+
251
+ calculateInnerSize(idx) {
252
+ // Cache the inner size to prevent too much calculation when resizing the window
253
+ clearTimeout(this.#cacheTimeout);
254
+ if (this.#cachedInnerSize) {
255
+ // Extend cache time
256
+ this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
257
+ } else {
258
+ let innerSize = 0;
259
+ let count = 1;
260
+ for (const c of this.element.children) {
261
+ if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push
262
+ if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items
263
+ innerSize += c.clientWidth;
264
+ count++;
265
+ }
266
+ innerSize += 8 * count;
267
+ this.#cachedInnerSize = innerSize;
268
+ this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
269
+ }
270
+ return this.#cachedInnerSize;
271
+ }
272
+
273
+ /**
274
+ * @param {string} defaultName
275
+ */
276
+ getFilename(defaultName) {
277
+ if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) {
278
+ defaultName = prompt("Save workflow as:", defaultName);
279
+ if (!defaultName) return;
280
+ if (!defaultName.toLowerCase().endsWith(".json")) {
281
+ defaultName += ".json";
282
+ }
283
+ }
284
+ return defaultName;
285
+ }
286
+
287
+ /**
288
+ * @param {string} [filename]
289
+ * @param { "workflow" | "output" } [promptProperty]
290
+ */
291
+ async exportWorkflow(filename, promptProperty) {
292
+ if (this.app.workflowManager.activeWorkflow?.path) {
293
+ filename = this.app.workflowManager.activeWorkflow.name;
294
+ }
295
+ const p = await this.app.graphToPrompt();
296
+ const json = JSON.stringify(p[promptProperty], null, 2);
297
+ const blob = new Blob([json], { type: "application/json" });
298
+ const file = this.getFilename(filename);
299
+ if (!file) return;
300
+ downloadBlob(file, blob);
301
+ }
302
+ }
ComfyUI/web/scripts/ui/menu/interruptButton.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { api } from "../../api.js";
4
+ import { ComfyButton } from "../components/button.js";
5
+
6
+ export function getInteruptButton(visibility) {
7
+ const btn = new ComfyButton({
8
+ icon: "close",
9
+ tooltip: "Cancel current generation",
10
+ enabled: false,
11
+ action: () => {
12
+ api.interrupt();
13
+ },
14
+ classList: ["comfyui-button", "comfyui-interrupt-button", visibility],
15
+ });
16
+
17
+ api.addEventListener("status", ({ detail }) => {
18
+ const sz = detail?.exec_info?.queue_remaining;
19
+ btn.enabled = sz > 0;
20
+ });
21
+
22
+ return btn;
23
+ }