jnkr36 commited on
Commit
9cd0422
1 Parent(s): 8e0d1ae

Upload 21 files

Browse files
web/extensions/core/colorPalette.js ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "/scripts/app.js";
2
+ import { $el } from "/scripts/ui.js";
3
+ import { api } from "/scripts/api.js";
4
+
5
+ // Manage color palettes
6
+
7
+ const colorPalettes = {
8
+ "palette_1": {
9
+ "id": "palette_1",
10
+ "name": "Palette 1",
11
+ "colors": {
12
+ "node_slot": {
13
+ "CLIP": "#FFD500", // bright yellow
14
+ "CLIP_VISION": "#A8DADC", // light blue-gray
15
+ "CLIP_VISION_OUTPUT": "#ad7452", // rusty brown-orange
16
+ "CONDITIONING": "#FFA931", // vibrant orange-yellow
17
+ "CONTROL_NET": "#6EE7B7", // soft mint green
18
+ "IMAGE": "#64B5F6", // bright sky blue
19
+ "LATENT": "#FF9CF9", // light pink-purple
20
+ "MASK": "#81C784", // muted green
21
+ "MODEL": "#B39DDB", // light lavender-purple
22
+ "STYLE_MODEL": "#C2FFAE", // light green-yellow
23
+ "VAE": "#FF6E6E", // bright red
24
+ }
25
+ }
26
+ },
27
+ "palette_2": {
28
+ "id": "palette_2",
29
+ "name": "Palette 2",
30
+ "colors": {
31
+ "node_slot": {
32
+ "CLIP": "#556B2F", // Dark Olive Green
33
+ "CLIP_VISION": "#4B0082", // Indigo
34
+ "CLIP_VISION_OUTPUT": "#006400", // Green
35
+ "CONDITIONING": "#FF1493", // Deep Pink
36
+ "CONTROL_NET": "#8B4513", // Saddle Brown
37
+ "IMAGE": "#8B0000", // Dark Red
38
+ "LATENT": "#00008B", // Dark Blue
39
+ "MASK": "#2F4F4F", // Dark Slate Grey
40
+ "MODEL": "#FF8C00", // Dark Orange
41
+ "STYLE_MODEL": "#004A4A", // Sherpa Blue
42
+ "UPSCALE_MODEL": "#4A004A", // Tyrian Purple
43
+ "VAE": "#4F394F", // Loulou
44
+ }
45
+ }
46
+ }
47
+ };
48
+
49
+ const id = "Comfy.ColorPalette";
50
+ const idCustomColorPalettes = "Comfy.CustomColorPalettes";
51
+ const defaultColorPaletteId = "palette_1";
52
+ const els = {}
53
+ // const ctxMenu = LiteGraph.ContextMenu;
54
+ app.registerExtension({
55
+ name: id,
56
+ init() {
57
+ const sortObjectKeys = (unordered) => {
58
+ return Object.keys(unordered).sort().reduce((obj, key) => {
59
+ obj[key] = unordered[key];
60
+ return obj;
61
+ }, {});
62
+ };
63
+
64
+ const getSlotTypes = async () => {
65
+ var types = [];
66
+
67
+ const defs = await api.getNodeDefs();
68
+ for (const nodeId in defs) {
69
+ const nodeData = defs[nodeId];
70
+
71
+ var inputs = nodeData["input"]["required"];
72
+ if (nodeData["input"]["optional"] != undefined){
73
+ inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
74
+ }
75
+
76
+ for (const inputName in inputs) {
77
+ const inputData = inputs[inputName];
78
+ const type = inputData[0];
79
+
80
+ if (!Array.isArray(type)) {
81
+ types.push(type);
82
+ }
83
+ }
84
+
85
+ for (const o in nodeData["output"]) {
86
+ const output = nodeData["output"][o];
87
+ types.push(output);
88
+ }
89
+ }
90
+
91
+ return types;
92
+ };
93
+
94
+ const completeColorPalette = async (colorPalette) => {
95
+ var types = await getSlotTypes();
96
+
97
+ for (const type of types) {
98
+ if (!colorPalette.colors.node_slot[type]) {
99
+ colorPalette.colors.node_slot[type] = "";
100
+ }
101
+ }
102
+
103
+ colorPalette.colors.node_slot = sortObjectKeys(colorPalette.colors.node_slot);
104
+
105
+ return colorPalette;
106
+ };
107
+
108
+ const getColorPaletteTemplate = async () => {
109
+ let colorPalette = {
110
+ "id": "my_color_palette_unique_id",
111
+ "name": "My Color Palette",
112
+ "colors": {
113
+ "node_slot": {
114
+ }
115
+ }
116
+ };
117
+
118
+ return completeColorPalette(colorPalette);
119
+ };
120
+
121
+ const getCustomColorPalettes = () => {
122
+ return app.ui.settings.getSettingValue(idCustomColorPalettes, {});
123
+ };
124
+
125
+ const setCustomColorPalettes = (customColorPalettes) => {
126
+ return app.ui.settings.setSettingValue(idCustomColorPalettes, customColorPalettes);
127
+ };
128
+
129
+ const addCustomColorPalette = async (colorPalette) => {
130
+ if (typeof(colorPalette) !== "object") {
131
+ app.ui.dialog.show("Invalid color palette");
132
+ return;
133
+ }
134
+
135
+ if (!colorPalette.id) {
136
+ app.ui.dialog.show("Color palette missing id");
137
+ return;
138
+ }
139
+
140
+ if (!colorPalette.name) {
141
+ app.ui.dialog.show("Color palette missing name");
142
+ return;
143
+ }
144
+
145
+ if (!colorPalette.colors) {
146
+ app.ui.dialog.show("Color palette missing colors");
147
+ return;
148
+ }
149
+
150
+ if (colorPalette.colors.node_slot && typeof(colorPalette.colors.node_slot) !== "object") {
151
+ app.ui.dialog.show("Invalid color palette colors.node_slot");
152
+ return;
153
+ }
154
+
155
+ let customColorPalettes = getCustomColorPalettes();
156
+ customColorPalettes[colorPalette.id] = colorPalette;
157
+ setCustomColorPalettes(customColorPalettes);
158
+
159
+ for (const option of els.select.childNodes) {
160
+ if (option.value === "custom_" + colorPalette.id) {
161
+ els.select.removeChild(option);
162
+ }
163
+ }
164
+
165
+ els.select.append($el("option", { textContent: colorPalette.name + " (custom)", value: "custom_" + colorPalette.id, selected: true }));
166
+
167
+ setColorPalette("custom_" + colorPalette.id);
168
+ await loadColorPalette(colorPalette);
169
+ };
170
+
171
+ const deleteCustomColorPalette = async (colorPaletteId) => {
172
+ let customColorPalettes = getCustomColorPalettes();
173
+ delete customColorPalettes[colorPaletteId];
174
+ setCustomColorPalettes(customColorPalettes);
175
+
176
+ for (const option of els.select.childNodes) {
177
+ if (option.value === defaultColorPaletteId) {
178
+ option.selected = true;
179
+ }
180
+
181
+ if (option.value === "custom_" + colorPaletteId) {
182
+ els.select.removeChild(option);
183
+ }
184
+ }
185
+
186
+ setColorPalette(defaultColorPaletteId);
187
+ await loadColorPalette(getColorPalette());
188
+ };
189
+
190
+ const loadColorPalette = async (colorPalette) => {
191
+ colorPalette = await completeColorPalette(colorPalette);
192
+ if (colorPalette.colors) {
193
+ if (colorPalette.colors.node_slot) {
194
+ Object.assign(app.canvas.default_connection_color_byType, colorPalette.colors.node_slot);
195
+ app.canvas.draw(true, true);
196
+ }
197
+ }
198
+ };
199
+
200
+ const getColorPalette = (colorPaletteId) => {
201
+ if (!colorPaletteId) {
202
+ colorPaletteId = app.ui.settings.getSettingValue(id, defaultColorPaletteId);
203
+ }
204
+
205
+ if (colorPaletteId.startsWith("custom_")) {
206
+ colorPaletteId = colorPaletteId.substr(7);
207
+ let customColorPalettes = getCustomColorPalettes();
208
+ if (customColorPalettes[colorPaletteId]) {
209
+ return customColorPalettes[colorPaletteId];
210
+ }
211
+ }
212
+
213
+ return colorPalettes[colorPaletteId];
214
+ };
215
+
216
+ const setColorPalette = (colorPaletteId) => {
217
+ app.ui.settings.setSettingValue(id, colorPaletteId);
218
+ };
219
+
220
+ const fileInput = $el("input", {
221
+ type: "file",
222
+ accept: ".json",
223
+ style: { display: "none" },
224
+ parent: document.body,
225
+ onchange: () => {
226
+ let file = fileInput.files[0];
227
+
228
+ if (file.type === "application/json" || file.name.endsWith(".json")) {
229
+ const reader = new FileReader();
230
+ reader.onload = async () => {
231
+ await addCustomColorPalette(JSON.parse(reader.result));
232
+ };
233
+ reader.readAsText(file);
234
+ }
235
+ },
236
+ });
237
+
238
+ app.ui.settings.addSetting({
239
+ id,
240
+ name: "Color Palette",
241
+ type: (name, setter, value) => {
242
+ let options = [];
243
+
244
+ for (const c in colorPalettes) {
245
+ const colorPalette = colorPalettes[c];
246
+ options.push($el("option", { textContent: colorPalette.name, value: colorPalette.id, selected: colorPalette.id === value }));
247
+ }
248
+
249
+ let customColorPalettes = getCustomColorPalettes();
250
+ for (const c in customColorPalettes) {
251
+ const colorPalette = customColorPalettes[c];
252
+ options.push($el("option", { textContent: colorPalette.name + " (custom)", value: "custom_" + colorPalette.id, selected: "custom_" + colorPalette.id === value }));
253
+ }
254
+
255
+ return $el("div", [
256
+ $el("label", { textContent: name || id }, [
257
+ els.select = $el("select", {
258
+ onchange: (e) => {
259
+ setter(e.target.value);
260
+ }
261
+ }, options)
262
+ ]),
263
+ $el("input", {
264
+ type: "button",
265
+ value: "Export",
266
+ onclick: async () => {
267
+ const colorPaletteId = app.ui.settings.getSettingValue(id, defaultColorPaletteId);
268
+ const colorPalette = await completeColorPalette(getColorPalette(colorPaletteId));
269
+ const json = JSON.stringify(colorPalette, null, 2); // convert the data to a JSON string
270
+ const blob = new Blob([json], { type: "application/json" });
271
+ const url = URL.createObjectURL(blob);
272
+ const a = $el("a", {
273
+ href: url,
274
+ download: colorPaletteId + ".json",
275
+ style: { display: "none" },
276
+ parent: document.body,
277
+ });
278
+ a.click();
279
+ setTimeout(function () {
280
+ a.remove();
281
+ window.URL.revokeObjectURL(url);
282
+ }, 0);
283
+ },
284
+ }),
285
+ $el("input", {
286
+ type: "button",
287
+ value: "Import",
288
+ onclick: () => {
289
+ fileInput.click();
290
+ }
291
+ }),
292
+ $el("input", {
293
+ type: "button",
294
+ value: "Template",
295
+ onclick: async () => {
296
+ const colorPalette = await getColorPaletteTemplate();
297
+ const json = JSON.stringify(colorPalette, null, 2); // convert the data to a JSON string
298
+ const blob = new Blob([json], { type: "application/json" });
299
+ const url = URL.createObjectURL(blob);
300
+ const a = $el("a", {
301
+ href: url,
302
+ download: "color_palette.json",
303
+ style: { display: "none" },
304
+ parent: document.body,
305
+ });
306
+ a.click();
307
+ setTimeout(function () {
308
+ a.remove();
309
+ window.URL.revokeObjectURL(url);
310
+ }, 0);
311
+ }
312
+ }),
313
+ $el("input", {
314
+ type: "button",
315
+ value: "Delete",
316
+ onclick: async () => {
317
+ let colorPaletteId = app.ui.settings.getSettingValue(id, defaultColorPaletteId);
318
+
319
+ if (colorPalettes[colorPaletteId]) {
320
+ app.ui.dialog.show("You cannot delete built-in color palette");
321
+ return;
322
+ }
323
+
324
+ if (colorPaletteId.startsWith("custom_")) {
325
+ colorPaletteId = colorPaletteId.substr(7);
326
+ }
327
+
328
+ await deleteCustomColorPalette(colorPaletteId);
329
+ }
330
+ }),
331
+ ]);
332
+ },
333
+ defaultValue: defaultColorPaletteId,
334
+ async onChange(value) {
335
+ if (!value) {
336
+ return;
337
+ }
338
+
339
+ if (colorPalettes[value]) {
340
+ await loadColorPalette(colorPalettes[value]);
341
+ } else if (value.startsWith("custom_")) {
342
+ value = value.substr(7);
343
+ let customColorPalettes = getCustomColorPalettes();
344
+ if (customColorPalettes[value]) {
345
+ await loadColorPalette(customColorPalettes[value]);
346
+ }
347
+ }
348
+ },
349
+ });
350
+ },
351
+ });
web/extensions/core/dynamicPrompts.js ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ app.registerExtension({
7
+ name: "Comfy.DynamicPrompts",
8
+ nodeCreated(node) {
9
+ if (node.widgets) {
10
+ // Locate dynamic prompt text widgets
11
+ // Include any widgets with dynamicPrompts set to true, and customtext
12
+ const widgets = node.widgets.filter(
13
+ (n) => (n.type === "customtext" && n.dynamicPrompts !== false) || n.dynamicPrompts
14
+ );
15
+ for (const widget of widgets) {
16
+ // Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
17
+ widget.serializeValue = (workflowNode, widgetIndex) => {
18
+ let prompt = widget.value;
19
+ while (prompt.replace("\\{", "").includes("{") && prompt.replace("\\}", "").includes("}")) {
20
+ const startIndex = prompt.replace("\\{", "00").indexOf("{");
21
+ const endIndex = prompt.replace("\\}", "00").indexOf("}");
22
+
23
+ const optionsString = prompt.substring(startIndex + 1, endIndex);
24
+ const options = optionsString.split("|");
25
+
26
+ const randomIndex = Math.floor(Math.random() * options.length);
27
+ const randomOption = options[randomIndex];
28
+
29
+ prompt = prompt.substring(0, startIndex) + randomOption + prompt.substring(endIndex + 1);
30
+ }
31
+
32
+ // Overwrite the value in the serialized workflow pnginfo
33
+ workflowNode.widgets_values[widgetIndex] = prompt;
34
+
35
+ return prompt;
36
+ };
37
+ }
38
+ }
39
+ },
40
+ });
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
+ const ctxMenu = LiteGraph.ContextMenu;
7
+ app.registerExtension({
8
+ name: id,
9
+ init() {
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
+ });
web/extensions/core/rerouteNode.js ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Node that allows you to redirect connections for cleaner graphs
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.RerouteNode",
7
+ registerCustomNodes() {
8
+ class RerouteNode {
9
+ constructor() {
10
+ if (!this.properties) {
11
+ this.properties = {};
12
+ }
13
+ this.properties.showOutputText = RerouteNode.defaultVisibility;
14
+
15
+ this.addInput("", "*");
16
+ this.addOutput(this.properties.showOutputText ? "*" : "", "*");
17
+
18
+ this.onConnectionsChange = function (type, index, connected, link_info) {
19
+ // Prevent multiple connections to different types when we have no input
20
+ if (connected && type === LiteGraph.OUTPUT) {
21
+ // Ignore wildcard nodes as these will be updated to real types
22
+ const types = new Set(this.outputs[0].links.map((l) => app.graph.links[l].type).filter((t) => t !== "*"));
23
+ if (types.size > 1) {
24
+ for (let i = 0; i < this.outputs[0].links.length - 1; i++) {
25
+ const linkId = this.outputs[0].links[i];
26
+ const link = app.graph.links[linkId];
27
+ const node = app.graph.getNodeById(link.target_id);
28
+ node.disconnectInput(link.target_slot);
29
+ }
30
+ }
31
+ }
32
+
33
+ // Find root input
34
+ let currentNode = this;
35
+ let updateNodes = [];
36
+ let inputType = null;
37
+ let inputNode = null;
38
+ while (currentNode) {
39
+ updateNodes.unshift(currentNode);
40
+ const linkId = currentNode.inputs[0].link;
41
+ if (linkId !== null) {
42
+ const link = app.graph.links[linkId];
43
+ const node = app.graph.getNodeById(link.origin_id);
44
+ const type = node.constructor.type;
45
+ if (type === "Reroute") {
46
+ if (node === this) {
47
+ // We've found a circle
48
+ currentNode.disconnectInput(link.target_slot);
49
+ currentNode = null;
50
+ }
51
+ else {
52
+ // Move the previous node
53
+ currentNode = node;
54
+ }
55
+ } else {
56
+ // We've found the end
57
+ inputNode = currentNode;
58
+ inputType = node.outputs[link.origin_slot].type;
59
+ break;
60
+ }
61
+ } else {
62
+ // This path has no input node
63
+ currentNode = null;
64
+ break;
65
+ }
66
+ }
67
+
68
+ // Find all outputs
69
+ const nodes = [this];
70
+ let outputType = null;
71
+ while (nodes.length) {
72
+ currentNode = nodes.pop();
73
+ const outputs = (currentNode.outputs ? currentNode.outputs[0].links : []) || [];
74
+ if (outputs.length) {
75
+ for (const linkId of outputs) {
76
+ const link = app.graph.links[linkId];
77
+
78
+ // When disconnecting sometimes the link is still registered
79
+ if (!link) continue;
80
+
81
+ const node = app.graph.getNodeById(link.target_id);
82
+ const type = node.constructor.type;
83
+
84
+ if (type === "Reroute") {
85
+ // Follow reroute nodes
86
+ nodes.push(node);
87
+ updateNodes.push(node);
88
+ } else {
89
+ // We've found an output
90
+ const nodeOutType = node.inputs[link.target_slot].type;
91
+ if (inputType && nodeOutType !== inputType) {
92
+ // The output doesnt match our input so disconnect it
93
+ node.disconnectInput(link.target_slot);
94
+ } else {
95
+ outputType = nodeOutType;
96
+ }
97
+ }
98
+ }
99
+ } else {
100
+ // No more outputs for this path
101
+ }
102
+ }
103
+
104
+ const displayType = inputType || outputType || "*";
105
+ const color = LGraphCanvas.link_type_colors[displayType];
106
+
107
+ // Update the types of each node
108
+ for (const node of updateNodes) {
109
+ // If we dont have an input type we are always wildcard but we'll show the output type
110
+ // This lets you change the output link to a different type and all nodes will update
111
+ node.outputs[0].type = inputType || "*";
112
+ node.__outputType = displayType;
113
+ node.outputs[0].name = node.properties.showOutputText ? displayType : "";
114
+ node.size = node.computeSize();
115
+
116
+ for (const l of node.outputs[0].links || []) {
117
+ const link = app.graph.links[l];
118
+ if (link) {
119
+ link.color = color;
120
+ }
121
+ }
122
+ }
123
+
124
+ if (inputNode) {
125
+ const link = app.graph.links[inputNode.inputs[0].link];
126
+ if (link) {
127
+ link.color = color;
128
+ }
129
+ }
130
+ };
131
+
132
+ this.clone = function () {
133
+ const cloned = RerouteNode.prototype.clone.apply(this);
134
+ cloned.removeOutput(0);
135
+ cloned.addOutput(this.properties.showOutputText ? "*" : "", "*");
136
+ cloned.size = cloned.computeSize();
137
+ return cloned;
138
+ };
139
+
140
+ // This node is purely frontend and does not impact the resulting prompt so should not be serialized
141
+ this.isVirtualNode = true;
142
+ }
143
+
144
+ getExtraMenuOptions(_, options) {
145
+ options.unshift(
146
+ {
147
+ content: (this.properties.showOutputText ? "Hide" : "Show") + " Type",
148
+ callback: () => {
149
+ this.properties.showOutputText = !this.properties.showOutputText;
150
+ if (this.properties.showOutputText) {
151
+ this.outputs[0].name = this.__outputType || this.outputs[0].type;
152
+ } else {
153
+ this.outputs[0].name = "";
154
+ }
155
+ this.size = this.computeSize();
156
+ app.graph.setDirtyCanvas(true, true);
157
+ },
158
+ },
159
+ {
160
+ content: (RerouteNode.defaultVisibility ? "Hide" : "Show") + " Type By Default",
161
+ callback: () => {
162
+ RerouteNode.setDefaultTextVisibility(!RerouteNode.defaultVisibility);
163
+ },
164
+ }
165
+ );
166
+ }
167
+
168
+ computeSize() {
169
+ return [
170
+ this.properties.showOutputText && this.outputs && this.outputs.length
171
+ ? Math.max(75, LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 + 40)
172
+ : 75,
173
+ 26,
174
+ ];
175
+ }
176
+
177
+ static setDefaultTextVisibility(visible) {
178
+ RerouteNode.defaultVisibility = visible;
179
+ if (visible) {
180
+ localStorage["Comfy.RerouteNode.DefaultVisibility"] = "true";
181
+ } else {
182
+ delete localStorage["Comfy.RerouteNode.DefaultVisibility"];
183
+ }
184
+ }
185
+ }
186
+
187
+ // Load default visibility
188
+ RerouteNode.setDefaultTextVisibility(!!localStorage["Comfy.RerouteNode.DefaultVisibility"]);
189
+
190
+ LiteGraph.registerNodeType(
191
+ "Reroute",
192
+ Object.assign(RerouteNode, {
193
+ title_mode: LiteGraph.NO_TITLE,
194
+ title: "Reroute",
195
+ collapsable: false,
196
+ })
197
+ );
198
+
199
+ RerouteNode.category = "utils";
200
+ },
201
+ });
web/extensions/core/saveImageExtraOutput.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "/scripts/app.js";
2
+
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
+
11
+ // Simple date formatter
12
+ const parts = {
13
+ d: (d) => d.getDate(),
14
+ M: (d) => d.getMonth() + 1,
15
+ h: (d) => d.getHours(),
16
+ m: (d) => d.getMinutes(),
17
+ s: (d) => d.getSeconds(),
18
+ };
19
+ const format =
20
+ Object.keys(parts)
21
+ .map((k) => k + k + "?")
22
+ .join("|") + "|yyy?y?";
23
+
24
+ function formatDate(text, date) {
25
+ return text.replace(new RegExp(format, "g"), function (text) {
26
+ if (text === "yy") return (date.getFullYear() + "").substring(2);
27
+ if (text === "yyyy") return date.getFullYear();
28
+ if (text[0] in parts) {
29
+ const p = parts[text[0]](date);
30
+ return (p + "").padStart(text.length, "0");
31
+ }
32
+ return text;
33
+ });
34
+ }
35
+
36
+ // When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R
37
+ nodeType.prototype.onNodeCreated = function () {
38
+ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
39
+
40
+ const widget = this.widgets.find((w) => w.name === "filename_prefix");
41
+ widget.serializeValue = () => {
42
+ return widget.value.replace(/%([^%]+)%/g, function (match, text) {
43
+ const split = text.split(".");
44
+ if (split.length !== 2) {
45
+ // Special handling for dates
46
+ if (split[0].startsWith("date:")) {
47
+ return formatDate(split[0].substring(5), new Date());
48
+ }
49
+
50
+ if (text !== "width" && text !== "height") {
51
+ // Dont warn on standard replacements
52
+ console.warn("Invalid replacement pattern", text);
53
+ }
54
+ return match;
55
+ }
56
+
57
+ // Find node with matching S&R property name
58
+ let nodes = app.graph._nodes.filter((n) => n.properties?.["Node name for S&R"] === split[0]);
59
+ // If we cant, see if there is a node with that title
60
+ if (!nodes.length) {
61
+ nodes = app.graph._nodes.filter((n) => n.title === split[0]);
62
+ }
63
+ if (!nodes.length) {
64
+ console.warn("Unable to find node", split[0]);
65
+ return match;
66
+ }
67
+
68
+ if (nodes.length > 1) {
69
+ console.warn("Multiple nodes matched", split[0], "using first match");
70
+ }
71
+
72
+ const node = nodes[0];
73
+
74
+ const widget = node.widgets?.find((w) => w.name === split[1]);
75
+ if (!widget) {
76
+ console.warn("Unable to find widget", split[1], "on node", split[0], node);
77
+ return match;
78
+ }
79
+
80
+ return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
81
+ });
82
+ };
83
+
84
+ return r;
85
+ };
86
+ } else {
87
+ // When any other node is created add a property to alias the node
88
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
89
+ nodeType.prototype.onNodeCreated = function () {
90
+ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
91
+
92
+ if (!this.properties || !("Node name for S&R" in this.properties)) {
93
+ this.addProperty("Node name for S&R", this.title, "string");
94
+ }
95
+
96
+ return r;
97
+ };
98
+ }
99
+ },
100
+ });
web/extensions/core/slotDefaults.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "/scripts/app.js";
2
+
3
+ // Adds defaults for quickly adding nodes with middle click on the input/output
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.SlotDefaults",
7
+ init() {
8
+ LiteGraph.middle_click_slot_add_default_node = true;
9
+ LiteGraph.slot_types_default_in = {
10
+ MODEL: "CheckpointLoaderSimple",
11
+ LATENT: "EmptyLatentImage",
12
+ VAE: "VAELoader",
13
+ };
14
+
15
+ LiteGraph.slot_types_default_out = {
16
+ LATENT: "VAEDecode",
17
+ IMAGE: "SaveImage",
18
+ CLIP: "CLIPTextEncode",
19
+ };
20
+ },
21
+ });
web/extensions/core/snapToGrid.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "/scripts/app.js";
2
+
3
+ // Shift + drag/resize to snap to grid
4
+
5
+ app.registerExtension({
6
+ name: "Comfy.SnapToGrid",
7
+ init() {
8
+ // Add setting to control grid size
9
+ app.ui.settings.addSetting({
10
+ id: "Comfy.SnapToGrid.GridSize",
11
+ name: "Grid Size",
12
+ type: "number",
13
+ attrs: {
14
+ min: 1,
15
+ max: 500,
16
+ },
17
+ tooltip:
18
+ "When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.",
19
+ defaultValue: LiteGraph.CANVAS_GRID_SIZE,
20
+ onChange(value) {
21
+ LiteGraph.CANVAS_GRID_SIZE = +value;
22
+ },
23
+ });
24
+
25
+ // After moving a node, if the shift key is down align it to grid
26
+ const onNodeMoved = app.canvas.onNodeMoved;
27
+ app.canvas.onNodeMoved = function (node) {
28
+ const r = onNodeMoved?.apply(this, arguments);
29
+
30
+ if (app.shiftDown) {
31
+ // Ensure all selected nodes are realigned
32
+ for (const id in this.selected_nodes) {
33
+ this.selected_nodes[id].alignToGrid();
34
+ }
35
+ }
36
+
37
+ return r;
38
+ };
39
+
40
+ // When a node is added, add a resize handler to it so we can fix align the size with the grid
41
+ const onNodeAdded = app.graph.onNodeAdded;
42
+ app.graph.onNodeAdded = function (node) {
43
+ const onResize = node.onResize;
44
+ node.onResize = function () {
45
+ if (app.shiftDown) {
46
+ const w = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.size[0] / LiteGraph.CANVAS_GRID_SIZE);
47
+ const h = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.size[1] / LiteGraph.CANVAS_GRID_SIZE);
48
+ node.size[0] = w;
49
+ node.size[1] = h;
50
+ }
51
+ return onResize?.apply(this, arguments);
52
+ };
53
+ return onNodeAdded?.apply(this, arguments);
54
+ };
55
+
56
+ // Draw a preview of where the node will go if holding shift and the node is selected
57
+ const origDrawNode = LGraphCanvas.prototype.drawNode;
58
+ LGraphCanvas.prototype.drawNode = function (node, ctx) {
59
+ if (app.shiftDown && this.node_dragged && node.id in this.selected_nodes) {
60
+ const x = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.pos[0] / LiteGraph.CANVAS_GRID_SIZE);
61
+ const y = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.pos[1] / LiteGraph.CANVAS_GRID_SIZE);
62
+
63
+ const shiftX = x - node.pos[0];
64
+ let shiftY = y - node.pos[1];
65
+
66
+ let w, h;
67
+ if (node.flags.collapsed) {
68
+ w = node._collapsed_width;
69
+ h = LiteGraph.NODE_TITLE_HEIGHT;
70
+ shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
71
+ } else {
72
+ w = node.size[0];
73
+ h = node.size[1];
74
+ let titleMode = node.constructor.title_mode;
75
+ if (titleMode !== LiteGraph.TRANSPARENT_TITLE && titleMode !== LiteGraph.NO_TITLE) {
76
+ h += LiteGraph.NODE_TITLE_HEIGHT;
77
+ shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
78
+ }
79
+ }
80
+ const f = ctx.fillStyle;
81
+ ctx.fillStyle = "rgba(100, 100, 100, 0.5)";
82
+ ctx.fillRect(shiftX, shiftY, w, h);
83
+ ctx.fillStyle = f;
84
+ }
85
+
86
+ return origDrawNode.apply(this, arguments);
87
+ };
88
+ },
89
+ });
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.name === "LoadImage" || nodeData.name === "LoadImageMask") {
9
+ nodeData.input.required.upload = ["IMAGEUPLOAD"];
10
+ }
11
+ },
12
+ });
web/extensions/core/widgetInputs.js ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComfyWidgets, addRandomizeWidget } from "/scripts/widgets.js";
2
+ import { app } from "/scripts/app.js";
3
+
4
+ const CONVERTED_TYPE = "converted-widget";
5
+ const VALID_TYPES = ["STRING", "combo", "number"];
6
+
7
+ function isConvertableWidget(widget, config) {
8
+ return VALID_TYPES.includes(widget.type) || VALID_TYPES.includes(config[0]);
9
+ }
10
+
11
+ function hideWidget(node, widget, suffix = "") {
12
+ widget.origType = widget.type;
13
+ widget.origComputeSize = widget.computeSize;
14
+ widget.origSerializeValue = widget.serializeValue;
15
+ widget.computeSize = () => [0, -4]; // -4 is due to the gap litegraph adds between widgets automatically
16
+ widget.type = CONVERTED_TYPE + suffix;
17
+ widget.serializeValue = () => {
18
+ // Prevent serializing the widget if we have no input linked
19
+ const { link } = node.inputs.find((i) => i.widget?.name === widget.name);
20
+ if (link == null) {
21
+ return undefined;
22
+ }
23
+ return widget.origSerializeValue ? widget.origSerializeValue() : widget.value;
24
+ };
25
+
26
+ // Hide any linked widgets, e.g. seed+randomize
27
+ if (widget.linkedWidgets) {
28
+ for (const w of widget.linkedWidgets) {
29
+ hideWidget(node, w, ":" + widget.name);
30
+ }
31
+ }
32
+ }
33
+
34
+ function showWidget(widget) {
35
+ widget.type = widget.origType;
36
+ widget.computeSize = widget.origComputeSize;
37
+ widget.serializeValue = widget.origSerializeValue;
38
+
39
+ delete widget.origType;
40
+ delete widget.origComputeSize;
41
+ delete widget.origSerializeValue;
42
+
43
+ // Hide any linked widgets, e.g. seed+randomize
44
+ if (widget.linkedWidgets) {
45
+ for (const w of widget.linkedWidgets) {
46
+ showWidget(w);
47
+ }
48
+ }
49
+ }
50
+
51
+ function convertToInput(node, widget, config) {
52
+ hideWidget(node, widget);
53
+
54
+ const { linkType } = getWidgetType(config);
55
+
56
+ // Add input and store widget config for creating on primitive node
57
+ const sz = node.size;
58
+ node.addInput(widget.name, linkType, {
59
+ widget: { name: widget.name, config },
60
+ });
61
+
62
+ // Restore original size but grow if needed
63
+ node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]);
64
+ }
65
+
66
+ function convertToWidget(node, widget) {
67
+ showWidget(widget);
68
+ const sz = node.size;
69
+ node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name));
70
+
71
+ // Restore original size but grow if needed
72
+ node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]);
73
+ }
74
+
75
+ function getWidgetType(config) {
76
+ // Special handling for COMBO so we restrict links based on the entries
77
+ let type = config[0];
78
+ let linkType = type;
79
+ if (type instanceof Array) {
80
+ type = "COMBO";
81
+ linkType = linkType.join(",");
82
+ }
83
+ return { type, linkType };
84
+ }
85
+
86
+ app.registerExtension({
87
+ name: "Comfy.WidgetInputs",
88
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
89
+ // Add menu options to conver to/from widgets
90
+ const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
91
+ nodeType.prototype.getExtraMenuOptions = function (_, options) {
92
+ const r = origGetExtraMenuOptions ? origGetExtraMenuOptions.apply(this, arguments) : undefined;
93
+
94
+ if (this.widgets) {
95
+ let toInput = [];
96
+ let toWidget = [];
97
+ for (const w of this.widgets) {
98
+ if (w.type === CONVERTED_TYPE) {
99
+ toWidget.push({
100
+ content: `Convert ${w.name} to widget`,
101
+ callback: () => convertToWidget(this, w),
102
+ });
103
+ } else {
104
+ const config = nodeData?.input?.required[w.name] || nodeData?.input?.optional?.[w.name] || [w.type, w.options || {}];
105
+ if (isConvertableWidget(w, config)) {
106
+ toInput.push({
107
+ content: `Convert ${w.name} to input`,
108
+ callback: () => convertToInput(this, w, config),
109
+ });
110
+ }
111
+ }
112
+ }
113
+ if (toInput.length) {
114
+ options.push(...toInput, null);
115
+ }
116
+
117
+ if (toWidget.length) {
118
+ options.push(...toWidget, null);
119
+ }
120
+ }
121
+
122
+ return r;
123
+ };
124
+
125
+ // On initial configure of nodes hide all converted widgets
126
+ const origOnConfigure = nodeType.prototype.onConfigure;
127
+ nodeType.prototype.onConfigure = function () {
128
+ const r = origOnConfigure ? origOnConfigure.apply(this, arguments) : undefined;
129
+
130
+ if (this.inputs) {
131
+ for (const input of this.inputs) {
132
+ if (input.widget) {
133
+ const w = this.widgets.find((w) => w.name === input.widget.name);
134
+ if (w) {
135
+ hideWidget(this, w);
136
+ } else {
137
+ convertToWidget(this, input)
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ return r;
144
+ };
145
+
146
+ function isNodeAtPos(pos) {
147
+ for (const n of app.graph._nodes) {
148
+ if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ // Double click a widget input to automatically attach a primitive
156
+ const origOnInputDblClick = nodeType.prototype.onInputDblClick;
157
+ const ignoreDblClick = Symbol();
158
+ nodeType.prototype.onInputDblClick = function (slot) {
159
+ const r = origOnInputDblClick ? origOnInputDblClick.apply(this, arguments) : undefined;
160
+
161
+ const input = this.inputs[slot];
162
+ if (input.widget && !input[ignoreDblClick]) {
163
+ const node = LiteGraph.createNode("PrimitiveNode");
164
+ app.graph.add(node);
165
+
166
+ // Calculate a position that wont directly overlap another node
167
+ const pos = [this.pos[0] - node.size[0] - 30, this.pos[1]];
168
+ while (isNodeAtPos(pos)) {
169
+ pos[1] += LiteGraph.NODE_TITLE_HEIGHT;
170
+ }
171
+
172
+ node.pos = pos;
173
+ node.connect(0, this, slot);
174
+ node.title = input.name;
175
+
176
+ // Prevent adding duplicates due to triple clicking
177
+ input[ignoreDblClick] = true;
178
+ setTimeout(() => {
179
+ delete input[ignoreDblClick];
180
+ }, 300);
181
+ }
182
+
183
+ return r;
184
+ };
185
+ },
186
+ registerCustomNodes() {
187
+ class PrimitiveNode {
188
+ constructor() {
189
+ this.addOutput("connect to widget input", "*");
190
+ this.serialize_widgets = true;
191
+ this.isVirtualNode = true;
192
+ }
193
+
194
+ applyToGraph() {
195
+ if (!this.outputs[0].links?.length) return;
196
+
197
+ // For each output link copy our value over the original widget value
198
+ for (const l of this.outputs[0].links) {
199
+ const linkInfo = app.graph.links[l];
200
+ const node = this.graph.getNodeById(linkInfo.target_id);
201
+ const input = node.inputs[linkInfo.target_slot];
202
+ const widgetName = input.widget.name;
203
+ if (widgetName) {
204
+ const widget = node.widgets.find((w) => w.name === widgetName);
205
+ if (widget) {
206
+ widget.value = this.widgets[0].value;
207
+ if (widget.callback) {
208
+ widget.callback(widget.value, app.canvas, node, app.canvas.graph_mouse, {});
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ onConnectionsChange(_, index, connected) {
216
+ if (connected) {
217
+ if (this.outputs[0].links?.length) {
218
+ if (!this.widgets?.length) {
219
+ this.#onFirstConnection();
220
+ }
221
+ if (!this.widgets?.length && this.outputs[0].widget) {
222
+ // On first load it often cant recreate the widget as the other node doesnt exist yet
223
+ // Manually recreate it from the output info
224
+ this.#createWidget(this.outputs[0].widget.config);
225
+ }
226
+ }
227
+ } else if (!this.outputs[0].links?.length) {
228
+ this.#onLastDisconnect();
229
+ }
230
+ }
231
+
232
+ onConnectOutput(slot, type, input, target_node, target_slot) {
233
+ // Fires before the link is made allowing us to reject it if it isn't valid
234
+
235
+ // No widget, we cant connect
236
+ if (!input.widget) return false;
237
+
238
+ if (this.outputs[slot].links?.length) {
239
+ return this.#isValidConnection(input);
240
+ }
241
+ }
242
+
243
+ #onFirstConnection() {
244
+ // First connection can fire before the graph is ready on initial load so random things can be missing
245
+ const linkId = this.outputs[0].links[0];
246
+ const link = this.graph.links[linkId];
247
+ if (!link) return;
248
+
249
+ const theirNode = this.graph.getNodeById(link.target_id);
250
+ if (!theirNode || !theirNode.inputs) return;
251
+
252
+ const input = theirNode.inputs[link.target_slot];
253
+ if (!input) return;
254
+
255
+ const widget = input.widget;
256
+ const { type, linkType } = getWidgetType(widget.config);
257
+
258
+ // Update our output to restrict to the widget type
259
+ this.outputs[0].type = linkType;
260
+ this.outputs[0].name = type;
261
+ this.outputs[0].widget = widget;
262
+
263
+ this.#createWidget(widget.config, theirNode, widget.name);
264
+ }
265
+
266
+ #createWidget(inputData, node, widgetName) {
267
+ let type = inputData[0];
268
+
269
+ if (type instanceof Array) {
270
+ type = "COMBO";
271
+ }
272
+
273
+ let widget;
274
+ if (type in ComfyWidgets) {
275
+ widget = (ComfyWidgets[type](this, "value", inputData, app) || {}).widget;
276
+ } else {
277
+ widget = this.addWidget(type, "value", null, () => {}, {});
278
+ }
279
+
280
+ if (node?.widgets && widget) {
281
+ const theirWidget = node.widgets.find((w) => w.name === widgetName);
282
+ if (theirWidget) {
283
+ widget.value = theirWidget.value;
284
+ }
285
+ }
286
+
287
+ if (widget.type === "number") {
288
+ addRandomizeWidget(this, widget, "Random after every gen");
289
+ }
290
+
291
+ // When our value changes, update other widgets to reflect our changes
292
+ // e.g. so LoadImage shows correct image
293
+ const callback = widget.callback;
294
+ const self = this;
295
+ widget.callback = function () {
296
+ const r = callback ? callback.apply(this, arguments) : undefined;
297
+ self.applyToGraph();
298
+ return r;
299
+ };
300
+
301
+ // Grow our node if required
302
+ const sz = this.computeSize();
303
+ if (this.size[0] < sz[0]) {
304
+ this.size[0] = sz[0];
305
+ }
306
+ if (this.size[1] < sz[1]) {
307
+ this.size[1] = sz[1];
308
+ }
309
+
310
+ requestAnimationFrame(() => {
311
+ if (this.onResize) {
312
+ this.onResize(this.size);
313
+ }
314
+ });
315
+ }
316
+
317
+ #isValidConnection(input) {
318
+ // Only allow connections where the configs match
319
+ const config1 = this.outputs[0].widget.config;
320
+ const config2 = input.widget.config;
321
+
322
+ if (config1[0] !== config2[0]) return false;
323
+
324
+ for (const k in config1[1]) {
325
+ if (k !== "default") {
326
+ if (config1[1][k] !== config2[1][k]) {
327
+ return false;
328
+ }
329
+ }
330
+ }
331
+
332
+ return true;
333
+ }
334
+
335
+ #onLastDisconnect() {
336
+ // We cant remove + re-add the output here as if you drag a link over the same link
337
+ // it removes, then re-adds, causing it to break
338
+ this.outputs[0].type = "*";
339
+ this.outputs[0].name = "connect to widget input";
340
+ delete this.outputs[0].widget;
341
+
342
+ if (this.widgets) {
343
+ // Allow widgets to cleanup
344
+ for (const w of this.widgets) {
345
+ if (w.onRemove) {
346
+ w.onRemove();
347
+ }
348
+ }
349
+ this.widgets.length = 0;
350
+ }
351
+ }
352
+ }
353
+
354
+ LiteGraph.registerNodeType(
355
+ "PrimitiveNode",
356
+ Object.assign(PrimitiveNode, {
357
+ title: "Primitive",
358
+ })
359
+ );
360
+ PrimitiveNode.category = "utils";
361
+ },
362
+ });
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);
web/index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <link rel="stylesheet" type="text/css" href="lib/litegraph.css" />
7
+ <link rel="stylesheet" type="text/css" href="style.css" />
8
+ <script type="text/javascript" src="lib/litegraph.core.js"></script>
9
+ <script type="module">
10
+ import { app } from "/scripts/app.js";
11
+ await app.setup();
12
+ window.app = app;
13
+ window.graph = app.graph;
14
+ </script>
15
+ </head>
16
+ <body></body>
17
+ </html>
web/jsconfig.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "/*": ["./*"]
6
+ }
7
+ },
8
+ "include": ["."]
9
+ }
web/lib/litegraph.core.js ADDED
The diff for this file is too large to render. See raw diff
 
web/lib/litegraph.css ADDED
@@ -0,0 +1,680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
188
+
189
+ .litegraph.litesearchbox .name {
190
+ display: inline-block;
191
+ min-width: 60px;
192
+ min-height: 1.5em;
193
+ padding-left: 10px;
194
+ }
195
+
196
+ .litegraph.litesearchbox .helper {
197
+ overflow: auto;
198
+ max-height: 200px;
199
+ margin-top: 2px;
200
+ }
201
+
202
+ .litegraph.lite-search-item {
203
+ font-family: Tahoma, sans-serif;
204
+ background-color: rgba(0, 0, 0, 0.5);
205
+ color: white;
206
+ padding-top: 2px;
207
+ }
208
+
209
+ .litegraph.lite-search-item.not_in_filter{
210
+ /*background-color: rgba(50, 50, 50, 0.5);*/
211
+ /*color: #999;*/
212
+ color: #B99;
213
+ font-style: italic;
214
+ }
215
+
216
+ .litegraph.lite-search-item.generic_type{
217
+ /*background-color: rgba(50, 50, 50, 0.5);*/
218
+ /*color: #DD9;*/
219
+ color: #999;
220
+ font-style: italic;
221
+ }
222
+
223
+ .litegraph.lite-search-item:hover,
224
+ .litegraph.lite-search-item.selected {
225
+ cursor: pointer;
226
+ background-color: white;
227
+ color: black;
228
+ }
229
+
230
+ /* DIALOGs ******/
231
+
232
+ .litegraph .dialog {
233
+ position: absolute;
234
+ top: 50%;
235
+ left: 50%;
236
+ margin-top: -150px;
237
+ margin-left: -200px;
238
+
239
+ background-color: #2A2A2A;
240
+
241
+ min-width: 400px;
242
+ min-height: 200px;
243
+ box-shadow: 0 0 4px #111;
244
+ border-radius: 6px;
245
+ }
246
+
247
+ .litegraph .dialog.settings {
248
+ left: 10px;
249
+ top: 10px;
250
+ height: calc( 100% - 20px );
251
+ margin: auto;
252
+ max-width: 50%;
253
+ }
254
+
255
+ .litegraph .dialog.centered {
256
+ top: 50px;
257
+ left: 50%;
258
+ position: absolute;
259
+ transform: translateX(-50%);
260
+ min-width: 600px;
261
+ min-height: 300px;
262
+ height: calc( 100% - 100px );
263
+ margin: auto;
264
+ }
265
+
266
+ .litegraph .dialog .close {
267
+ float: right;
268
+ margin: 4px;
269
+ margin-right: 10px;
270
+ cursor: pointer;
271
+ font-size: 1.4em;
272
+ }
273
+
274
+ .litegraph .dialog .close:hover {
275
+ color: white;
276
+ }
277
+
278
+ .litegraph .dialog .dialog-header {
279
+ color: #AAA;
280
+ border-bottom: 1px solid #161616;
281
+ }
282
+
283
+ .litegraph .dialog .dialog-header { height: 40px; }
284
+ .litegraph .dialog .dialog-footer { height: 50px; padding: 10px; border-top: 1px solid #1a1a1a;}
285
+
286
+ .litegraph .dialog .dialog-header .dialog-title {
287
+ font: 20px "Arial";
288
+ margin: 4px;
289
+ padding: 4px 10px;
290
+ display: inline-block;
291
+ }
292
+
293
+ .litegraph .dialog .dialog-content, .litegraph .dialog .dialog-alt-content {
294
+ height: calc(100% - 90px);
295
+ width: 100%;
296
+ min-height: 100px;
297
+ display: inline-block;
298
+ color: #AAA;
299
+ /*background-color: black;*/
300
+ overflow: auto;
301
+ }
302
+
303
+ .litegraph .dialog .dialog-content h3 {
304
+ margin: 10px;
305
+ }
306
+
307
+ .litegraph .dialog .dialog-content .connections {
308
+ flex-direction: row;
309
+ }
310
+
311
+ .litegraph .dialog .dialog-content .connections .connections_side {
312
+ width: calc(50% - 5px);
313
+ min-height: 100px;
314
+ background-color: black;
315
+ display: flex;
316
+ }
317
+
318
+ .litegraph .dialog .node_type {
319
+ font-size: 1.2em;
320
+ display: block;
321
+ margin: 10px;
322
+ }
323
+
324
+ .litegraph .dialog .node_desc {
325
+ opacity: 0.5;
326
+ display: block;
327
+ margin: 10px;
328
+ }
329
+
330
+ .litegraph .dialog .separator {
331
+ display: block;
332
+ width: calc( 100% - 4px );
333
+ height: 1px;
334
+ border-top: 1px solid #000;
335
+ border-bottom: 1px solid #333;
336
+ margin: 10px 2px;
337
+ padding: 0;
338
+ }
339
+
340
+ .litegraph .dialog .property {
341
+ margin-bottom: 2px;
342
+ padding: 4px;
343
+ }
344
+
345
+ .litegraph .dialog .property:hover {
346
+ background: #545454;
347
+ }
348
+
349
+ .litegraph .dialog .property_name {
350
+ color: #737373;
351
+ display: inline-block;
352
+ text-align: left;
353
+ vertical-align: top;
354
+ width: 160px;
355
+ padding-left: 4px;
356
+ overflow: hidden;
357
+ margin-right: 6px;
358
+ }
359
+
360
+ .litegraph .dialog .property:hover .property_name {
361
+ color: white;
362
+ }
363
+
364
+ .litegraph .dialog .property_value {
365
+ display: inline-block;
366
+ text-align: right;
367
+ color: #AAA;
368
+ background-color: #1A1A1A;
369
+ /*width: calc( 100% - 122px );*/
370
+ max-width: calc( 100% - 162px );
371
+ min-width: 200px;
372
+ max-height: 300px;
373
+ min-height: 20px;
374
+ padding: 4px;
375
+ padding-right: 12px;
376
+ overflow: hidden;
377
+ cursor: pointer;
378
+ border-radius: 3px;
379
+ }
380
+
381
+ .litegraph .dialog .property_value:hover {
382
+ color: white;
383
+ }
384
+
385
+ .litegraph .dialog .property.boolean .property_value {
386
+ padding-right: 30px;
387
+ color: #A88;
388
+ /*width: auto;
389
+ float: right;*/
390
+ }
391
+
392
+ .litegraph .dialog .property.boolean.bool-on .property_name{
393
+ color: #8A8;
394
+ }
395
+ .litegraph .dialog .property.boolean.bool-on .property_value{
396
+ color: #8A8;
397
+ }
398
+
399
+ .litegraph .dialog .btn {
400
+ border: 0;
401
+ border-radius: 4px;
402
+ padding: 4px 20px;
403
+ margin-left: 0px;
404
+ background-color: #060606;
405
+ color: #8e8e8e;
406
+ }
407
+
408
+ .litegraph .dialog .btn:hover {
409
+ background-color: #111;
410
+ color: #FFF;
411
+ }
412
+
413
+ .litegraph .dialog .btn.delete:hover {
414
+ background-color: #F33;
415
+ color: black;
416
+ }
417
+
418
+ .litegraph .subgraph_property {
419
+ padding: 4px;
420
+ }
421
+
422
+ .litegraph .subgraph_property:hover {
423
+ background-color: #333;
424
+ }
425
+
426
+ .litegraph .subgraph_property.extra {
427
+ margin-top: 8px;
428
+ }
429
+
430
+ .litegraph .subgraph_property span.name {
431
+ font-size: 1.3em;
432
+ padding-left: 4px;
433
+ }
434
+
435
+ .litegraph .subgraph_property span.type {
436
+ opacity: 0.5;
437
+ margin-right: 20px;
438
+ padding-left: 4px;
439
+ }
440
+
441
+ .litegraph .subgraph_property span.label {
442
+ display: inline-block;
443
+ width: 60px;
444
+ padding: 0px 10px;
445
+ }
446
+
447
+ .litegraph .subgraph_property input {
448
+ width: 140px;
449
+ color: #999;
450
+ background-color: #1A1A1A;
451
+ border-radius: 4px;
452
+ border: 0;
453
+ margin-right: 10px;
454
+ padding: 4px;
455
+ padding-left: 10px;
456
+ }
457
+
458
+ .litegraph .subgraph_property button {
459
+ background-color: #1c1c1c;
460
+ color: #aaa;
461
+ border: 0;
462
+ border-radius: 2px;
463
+ padding: 4px 10px;
464
+ cursor: pointer;
465
+ }
466
+
467
+ .litegraph .subgraph_property.extra {
468
+ color: #ccc;
469
+ }
470
+
471
+ .litegraph .subgraph_property.extra input {
472
+ background-color: #111;
473
+ }
474
+
475
+ .litegraph .bullet_icon {
476
+ margin-left: 10px;
477
+ border-radius: 10px;
478
+ width: 12px;
479
+ height: 12px;
480
+ background-color: #666;
481
+ display: inline-block;
482
+ margin-top: 2px;
483
+ margin-right: 4px;
484
+ transition: background-color 0.1s ease 0s;
485
+ -moz-transition: background-color 0.1s ease 0s;
486
+ }
487
+
488
+ .litegraph .bullet_icon:hover {
489
+ background-color: #698;
490
+ cursor: pointer;
491
+ }
492
+
493
+ /* OLD */
494
+
495
+ .graphcontextmenu {
496
+ padding: 4px;
497
+ min-width: 100px;
498
+ }
499
+
500
+ .graphcontextmenu-title {
501
+ color: #dde;
502
+ background-color: #222;
503
+ margin: 0;
504
+ padding: 2px;
505
+ cursor: default;
506
+ }
507
+
508
+ .graphmenu-entry {
509
+ box-sizing: border-box;
510
+ margin: 2px;
511
+ padding-left: 20px;
512
+ user-select: none;
513
+ -moz-user-select: none;
514
+ -webkit-user-select: none;
515
+ transition: all linear 0.3s;
516
+ }
517
+
518
+ .graphmenu-entry.event,
519
+ .litemenu-entry.event {
520
+ border-left: 8px solid orange;
521
+ padding-left: 12px;
522
+ }
523
+
524
+ .graphmenu-entry.disabled {
525
+ opacity: 0.3;
526
+ }
527
+
528
+ .graphmenu-entry.submenu {
529
+ border-right: 2px solid #eee;
530
+ }
531
+
532
+ .graphmenu-entry:hover {
533
+ background-color: #555;
534
+ }
535
+
536
+ .graphmenu-entry.separator {
537
+ background-color: #111;
538
+ border-bottom: 1px solid #666;
539
+ height: 1px;
540
+ width: calc(100% - 20px);
541
+ -moz-width: calc(100% - 20px);
542
+ -webkit-width: calc(100% - 20px);
543
+ }
544
+
545
+ .graphmenu-entry .property_name {
546
+ display: inline-block;
547
+ text-align: left;
548
+ min-width: 80px;
549
+ min-height: 1.2em;
550
+ }
551
+
552
+ .graphmenu-entry .property_value,
553
+ .litemenu-entry .property_value {
554
+ display: inline-block;
555
+ background-color: rgba(0, 0, 0, 0.5);
556
+ text-align: right;
557
+ min-width: 80px;
558
+ min-height: 1.2em;
559
+ vertical-align: middle;
560
+ padding-right: 10px;
561
+ }
562
+
563
+ .graphdialog {
564
+ position: absolute;
565
+ top: 10px;
566
+ left: 10px;
567
+ min-height: 2em;
568
+ background-color: #333;
569
+ font-size: 1.2em;
570
+ box-shadow: 0 0 10px black !important;
571
+ z-index: 10;
572
+ }
573
+
574
+ .graphdialog.rounded {
575
+ border-radius: 12px;
576
+ padding-right: 2px;
577
+ }
578
+
579
+ .graphdialog .name {
580
+ display: inline-block;
581
+ min-width: 60px;
582
+ min-height: 1.5em;
583
+ padding-left: 10px;
584
+ }
585
+
586
+ .graphdialog input,
587
+ .graphdialog textarea,
588
+ .graphdialog select {
589
+ margin: 3px;
590
+ min-width: 60px;
591
+ min-height: 1.5em;
592
+ background-color: black;
593
+ border: 0;
594
+ color: white;
595
+ padding-left: 10px;
596
+ outline: none;
597
+ }
598
+
599
+ .graphdialog textarea {
600
+ min-height: 150px;
601
+ }
602
+
603
+ .graphdialog button {
604
+ margin-top: 3px;
605
+ vertical-align: top;
606
+ background-color: #999;
607
+ border: 0;
608
+ }
609
+
610
+ .graphdialog button.rounded,
611
+ .graphdialog input.rounded {
612
+ border-radius: 0 12px 12px 0;
613
+ }
614
+
615
+ .graphdialog .helper {
616
+ overflow: auto;
617
+ max-height: 200px;
618
+ }
619
+
620
+ .graphdialog .help-item {
621
+ padding-left: 10px;
622
+ }
623
+
624
+ .graphdialog .help-item:hover,
625
+ .graphdialog .help-item.selected {
626
+ cursor: pointer;
627
+ background-color: white;
628
+ color: black;
629
+ }
630
+
631
+ .litegraph .dialog {
632
+ min-height: 0;
633
+ }
634
+ .litegraph .dialog .dialog-content {
635
+ display: block;
636
+ }
637
+ .litegraph .dialog .dialog-content .subgraph_property {
638
+ padding: 5px;
639
+ }
640
+ .litegraph .dialog .dialog-footer {
641
+ margin: 0;
642
+ }
643
+ .litegraph .dialog .dialog-footer .subgraph_property {
644
+ margin-top: 0;
645
+ display: flex;
646
+ align-items: center;
647
+ padding: 5px;
648
+ }
649
+ .litegraph .dialog .dialog-footer .subgraph_property .name {
650
+ flex: 1;
651
+ }
652
+ .litegraph .graphdialog {
653
+ display: flex;
654
+ align-items: center;
655
+ border-radius: 20px;
656
+ padding: 4px 10px;
657
+ position: fixed;
658
+ }
659
+ .litegraph .graphdialog .name {
660
+ padding: 0;
661
+ min-height: 0;
662
+ font-size: 16px;
663
+ vertical-align: middle;
664
+ }
665
+ .litegraph .graphdialog .value {
666
+ font-size: 16px;
667
+ min-height: 0;
668
+ margin: 0 10px;
669
+ padding: 2px 5px;
670
+ }
671
+ .litegraph .graphdialog input[type="checkbox"] {
672
+ width: 16px;
673
+ height: 16px;
674
+ }
675
+ .litegraph .graphdialog button {
676
+ padding: 4px 18px;
677
+ border-radius: 20px;
678
+ cursor: pointer;
679
+ }
680
+
web/scripts/api.js ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class ComfyApi extends EventTarget {
2
+ #registered = new Set();
3
+
4
+ constructor() {
5
+ super();
6
+ }
7
+
8
+ addEventListener(type, callback, options) {
9
+ super.addEventListener(type, callback, options);
10
+ this.#registered.add(type);
11
+ }
12
+
13
+ /**
14
+ * Poll status for colab and other things that don't support websockets.
15
+ */
16
+ #pollQueue() {
17
+ setInterval(async () => {
18
+ try {
19
+ const resp = await fetch("/prompt");
20
+ const status = await resp.json();
21
+ this.dispatchEvent(new CustomEvent("status", { detail: status }));
22
+ } catch (error) {
23
+ this.dispatchEvent(new CustomEvent("status", { detail: null }));
24
+ }
25
+ }, 1000);
26
+ }
27
+
28
+ /**
29
+ * Creates and connects a WebSocket for realtime updates
30
+ * @param {boolean} isReconnect If the socket is connection is a reconnect attempt
31
+ */
32
+ #createSocket(isReconnect) {
33
+ if (this.socket) {
34
+ return;
35
+ }
36
+
37
+ let opened = false;
38
+ let existingSession = sessionStorage["Comfy.SessionId"] || "";
39
+ if (existingSession) {
40
+ existingSession = "?clientId=" + existingSession;
41
+ }
42
+ this.socket = new WebSocket(
43
+ `ws${window.location.protocol === "https:" ? "s" : ""}://${location.host}/ws${existingSession}`
44
+ );
45
+
46
+ this.socket.addEventListener("open", () => {
47
+ opened = true;
48
+ if (isReconnect) {
49
+ this.dispatchEvent(new CustomEvent("reconnected"));
50
+ }
51
+ });
52
+
53
+ this.socket.addEventListener("error", () => {
54
+ if (this.socket) this.socket.close();
55
+ if (!isReconnect && !opened) {
56
+ this.#pollQueue();
57
+ }
58
+ });
59
+
60
+ this.socket.addEventListener("close", () => {
61
+ setTimeout(() => {
62
+ this.socket = null;
63
+ this.#createSocket(true);
64
+ }, 300);
65
+ if (opened) {
66
+ this.dispatchEvent(new CustomEvent("status", { detail: null }));
67
+ this.dispatchEvent(new CustomEvent("reconnecting"));
68
+ }
69
+ });
70
+
71
+ this.socket.addEventListener("message", (event) => {
72
+ try {
73
+ const msg = JSON.parse(event.data);
74
+ switch (msg.type) {
75
+ case "status":
76
+ if (msg.data.sid) {
77
+ this.clientId = msg.data.sid;
78
+ sessionStorage["Comfy.SessionId"] = this.clientId;
79
+ }
80
+ this.dispatchEvent(new CustomEvent("status", { detail: msg.data.status }));
81
+ break;
82
+ case "progress":
83
+ this.dispatchEvent(new CustomEvent("progress", { detail: msg.data }));
84
+ break;
85
+ case "executing":
86
+ this.dispatchEvent(new CustomEvent("executing", { detail: msg.data.node }));
87
+ break;
88
+ case "executed":
89
+ this.dispatchEvent(new CustomEvent("executed", { detail: msg.data }));
90
+ break;
91
+ default:
92
+ if (this.#registered.has(msg.type)) {
93
+ this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }));
94
+ } else {
95
+ throw new Error("Unknown message type");
96
+ }
97
+ }
98
+ } catch (error) {
99
+ console.warn("Unhandled message:", event.data);
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Initialises sockets and realtime updates
106
+ */
107
+ init() {
108
+ this.#createSocket();
109
+ }
110
+
111
+ /**
112
+ * Gets a list of extension urls
113
+ * @returns An array of script urls to import
114
+ */
115
+ async getExtensions() {
116
+ const resp = await fetch("/extensions", { cache: "no-store" });
117
+ return await resp.json();
118
+ }
119
+
120
+ /**
121
+ * Gets a list of embedding names
122
+ * @returns An array of script urls to import
123
+ */
124
+ async getEmbeddings() {
125
+ const resp = await fetch("/embeddings", { cache: "no-store" });
126
+ return await resp.json();
127
+ }
128
+
129
+ /**
130
+ * Loads node object definitions for the graph
131
+ * @returns The node definitions
132
+ */
133
+ async getNodeDefs() {
134
+ const resp = await fetch("object_info", { cache: "no-store" });
135
+ return await resp.json();
136
+ }
137
+
138
+ /**
139
+ *
140
+ * @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
141
+ * @param {object} prompt The prompt data to queue
142
+ */
143
+ async queuePrompt(number, { output, workflow }) {
144
+ const body = {
145
+ client_id: this.clientId,
146
+ prompt: output,
147
+ extra_data: { extra_pnginfo: { workflow } },
148
+ };
149
+
150
+ if (number === -1) {
151
+ body.front = true;
152
+ } else if (number != 0) {
153
+ body.number = number;
154
+ }
155
+
156
+ const res = await fetch("/prompt", {
157
+ method: "POST",
158
+ headers: {
159
+ "Content-Type": "application/json",
160
+ },
161
+ body: JSON.stringify(body),
162
+ });
163
+
164
+ if (res.status !== 200) {
165
+ throw {
166
+ response: await res.text(),
167
+ };
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Loads a list of items (queue or history)
173
+ * @param {string} type The type of items to load, queue or history
174
+ * @returns The items of the specified type grouped by their status
175
+ */
176
+ async getItems(type) {
177
+ if (type === "queue") {
178
+ return this.getQueue();
179
+ }
180
+ return this.getHistory();
181
+ }
182
+
183
+ /**
184
+ * Gets the current state of the queue
185
+ * @returns The currently running and queued items
186
+ */
187
+ async getQueue() {
188
+ try {
189
+ const res = await fetch("/queue");
190
+ const data = await res.json();
191
+ return {
192
+ // Running action uses a different endpoint for cancelling
193
+ Running: data.queue_running.map((prompt) => ({
194
+ prompt,
195
+ remove: { name: "Cancel", cb: () => api.interrupt() },
196
+ })),
197
+ Pending: data.queue_pending.map((prompt) => ({ prompt })),
198
+ };
199
+ } catch (error) {
200
+ console.error(error);
201
+ return { Running: [], Pending: [] };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Gets the prompt execution history
207
+ * @returns Prompt history including node outputs
208
+ */
209
+ async getHistory() {
210
+ try {
211
+ const res = await fetch("/history");
212
+ return { History: Object.values(await res.json()) };
213
+ } catch (error) {
214
+ console.error(error);
215
+ return { History: [] };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Sends a POST request to the API
221
+ * @param {*} type The endpoint to post to
222
+ * @param {*} body Optional POST data
223
+ */
224
+ async #postItem(type, body) {
225
+ try {
226
+ await fetch("/" + type, {
227
+ method: "POST",
228
+ headers: {
229
+ "Content-Type": "application/json",
230
+ },
231
+ body: body ? JSON.stringify(body) : undefined,
232
+ });
233
+ } catch (error) {
234
+ console.error(error);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Deletes an item from the specified list
240
+ * @param {string} type The type of item to delete, queue or history
241
+ * @param {number} id The id of the item to delete
242
+ */
243
+ async deleteItem(type, id) {
244
+ await this.#postItem(type, { delete: [id] });
245
+ }
246
+
247
+ /**
248
+ * Clears the specified list
249
+ * @param {string} type The type of list to clear, queue or history
250
+ */
251
+ async clearItems(type) {
252
+ await this.#postItem(type, { clear: true });
253
+ }
254
+
255
+ /**
256
+ * Interrupts the execution of the running prompt
257
+ */
258
+ async interrupt() {
259
+ await this.#postItem("interrupt", null);
260
+ }
261
+ }
262
+
263
+ export const api = new ComfyApi();
web/scripts/app.js ADDED
@@ -0,0 +1,1046 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComfyWidgets } from "./widgets.js";
2
+ import { ComfyUI } from "./ui.js";
3
+ import { api } from "./api.js";
4
+ import { defaultGraph } from "./defaultGraph.js";
5
+ import { getPngMetadata, importA1111 } from "./pnginfo.js";
6
+
7
+ class ComfyApp {
8
+ /**
9
+ * List of {number, batchCount} entries to queue
10
+ */
11
+ #queueItems = [];
12
+ /**
13
+ * If the queue is currently being processed
14
+ */
15
+ #processingQueue = false;
16
+
17
+ constructor() {
18
+ this.ui = new ComfyUI(this);
19
+ this.extensions = [];
20
+ this.nodeOutputs = {};
21
+ this.shiftDown = false;
22
+ }
23
+
24
+ /**
25
+ * Invoke an extension callback
26
+ * @param {string} method The extension callback to execute
27
+ * @param {...any} args Any arguments to pass to the callback
28
+ * @returns
29
+ */
30
+ #invokeExtensions(method, ...args) {
31
+ let results = [];
32
+ for (const ext of this.extensions) {
33
+ if (method in ext) {
34
+ try {
35
+ results.push(ext[method](...args, this));
36
+ } catch (error) {
37
+ console.error(
38
+ `Error calling extension '${ext.name}' method '${method}'`,
39
+ { error },
40
+ { extension: ext },
41
+ { args }
42
+ );
43
+ }
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+
49
+ /**
50
+ * Invoke an async extension callback
51
+ * Each callback will be invoked concurrently
52
+ * @param {string} method The extension callback to execute
53
+ * @param {...any} args Any arguments to pass to the callback
54
+ * @returns
55
+ */
56
+ async #invokeExtensionsAsync(method, ...args) {
57
+ return await Promise.all(
58
+ this.extensions.map(async (ext) => {
59
+ if (method in ext) {
60
+ try {
61
+ return await ext[method](...args, this);
62
+ } catch (error) {
63
+ console.error(
64
+ `Error calling extension '${ext.name}' method '${method}'`,
65
+ { error },
66
+ { extension: ext },
67
+ { args }
68
+ );
69
+ }
70
+ }
71
+ })
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Adds special context menu handling for nodes
77
+ * e.g. this adds Open Image functionality for nodes that show images
78
+ * @param {*} node The node to add the menu handler
79
+ */
80
+ #addNodeContextMenuHandler(node) {
81
+ node.prototype.getExtraMenuOptions = function (_, options) {
82
+ if (this.imgs) {
83
+ // If this node has images then we add an open in new tab item
84
+ let img;
85
+ if (this.imageIndex != null) {
86
+ // An image is selected so select that
87
+ img = this.imgs[this.imageIndex];
88
+ } else if (this.overIndex != null) {
89
+ // No image is selected but one is hovered
90
+ img = this.imgs[this.overIndex];
91
+ }
92
+ if (img) {
93
+ options.unshift(
94
+ {
95
+ content: "Open Image",
96
+ callback: () => window.open(img.src, "_blank"),
97
+ },
98
+ {
99
+ content: "Save Image",
100
+ callback: () => {
101
+ const a = document.createElement("a");
102
+ a.href = img.src;
103
+ a.setAttribute("download", new URLSearchParams(new URL(img.src).search).get("filename"));
104
+ document.body.append(a);
105
+ a.click();
106
+ requestAnimationFrame(() => a.remove());
107
+ },
108
+ }
109
+ );
110
+ }
111
+ }
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Adds Custom drawing logic for nodes
117
+ * e.g. Draws images and handles thumbnail navigation on nodes that output images
118
+ * @param {*} node The node to add the draw handler
119
+ */
120
+ #addDrawBackgroundHandler(node) {
121
+ const app = this;
122
+ node.prototype.onDrawBackground = function (ctx) {
123
+ if (!this.flags.collapsed) {
124
+ const output = app.nodeOutputs[this.id + ""];
125
+ if (output && output.images) {
126
+ if (this.images !== output.images) {
127
+ this.images = output.images;
128
+ this.imgs = null;
129
+ this.imageIndex = null;
130
+ Promise.all(
131
+ output.images.map((src) => {
132
+ return new Promise((r) => {
133
+ const img = new Image();
134
+ img.onload = () => r(img);
135
+ img.onerror = () => r(null);
136
+ img.src = "/view?" + new URLSearchParams(src).toString();
137
+ });
138
+ })
139
+ ).then((imgs) => {
140
+ if (this.images === output.images) {
141
+ this.imgs = imgs.filter(Boolean);
142
+ if (this.size[1] < 100) {
143
+ this.size[1] = 250;
144
+ }
145
+ app.graph.setDirtyCanvas(true);
146
+ }
147
+ });
148
+ }
149
+ }
150
+
151
+ if (this.imgs && this.imgs.length) {
152
+ const canvas = graph.list_of_graphcanvas[0];
153
+ const mouse = canvas.graph_mouse;
154
+ if (!canvas.pointer_is_down && this.pointerDown) {
155
+ if (mouse[0] === this.pointerDown.pos[0] && mouse[1] === this.pointerDown.pos[1]) {
156
+ this.imageIndex = this.pointerDown.index;
157
+ }
158
+ this.pointerDown = null;
159
+ }
160
+
161
+ let w = this.imgs[0].naturalWidth;
162
+ let h = this.imgs[0].naturalHeight;
163
+ let imageIndex = this.imageIndex;
164
+ const numImages = this.imgs.length;
165
+ if (numImages === 1 && !imageIndex) {
166
+ this.imageIndex = imageIndex = 0;
167
+ }
168
+
169
+ let shiftY;
170
+ if (this.imageOffset != null) {
171
+ shiftY = this.imageOffset;
172
+ } else {
173
+ shiftY = this.computeSize()[1];
174
+ }
175
+
176
+ let dw = this.size[0];
177
+ let dh = this.size[1];
178
+ dh -= shiftY;
179
+
180
+ if (imageIndex == null) {
181
+ let best = 0;
182
+ let cellWidth;
183
+ let cellHeight;
184
+ let cols = 0;
185
+ let shiftX = 0;
186
+ for (let c = 1; c <= numImages; c++) {
187
+ const rows = Math.ceil(numImages / c);
188
+ const cW = dw / c;
189
+ const cH = dh / rows;
190
+ const scaleX = cW / w;
191
+ const scaleY = cH / h;
192
+
193
+ const scale = Math.min(scaleX, scaleY, 1);
194
+ const imageW = w * scale;
195
+ const imageH = h * scale;
196
+ const area = imageW * imageH * numImages;
197
+
198
+ if (area > best) {
199
+ best = area;
200
+ cellWidth = imageW;
201
+ cellHeight = imageH;
202
+ cols = c;
203
+ shiftX = c * ((cW - imageW) / 2);
204
+ }
205
+ }
206
+
207
+ let anyHovered = false;
208
+ this.imageRects = [];
209
+ for (let i = 0; i < numImages; i++) {
210
+ const img = this.imgs[i];
211
+ const row = Math.floor(i / cols);
212
+ const col = i % cols;
213
+ const x = col * cellWidth + shiftX;
214
+ const y = row * cellHeight + shiftY;
215
+ if (!anyHovered) {
216
+ anyHovered = LiteGraph.isInsideRectangle(
217
+ mouse[0],
218
+ mouse[1],
219
+ x + this.pos[0],
220
+ y + this.pos[1],
221
+ cellWidth,
222
+ cellHeight
223
+ );
224
+ if (anyHovered) {
225
+ this.overIndex = i;
226
+ let value = 110;
227
+ if (canvas.pointer_is_down) {
228
+ if (!this.pointerDown || this.pointerDown.index !== i) {
229
+ this.pointerDown = { index: i, pos: [...mouse] };
230
+ }
231
+ value = 125;
232
+ }
233
+ ctx.filter = `contrast(${value}%) brightness(${value}%)`;
234
+ canvas.canvas.style.cursor = "pointer";
235
+ }
236
+ }
237
+ this.imageRects.push([x, y, cellWidth, cellHeight]);
238
+ ctx.drawImage(img, x, y, cellWidth, cellHeight);
239
+ ctx.filter = "none";
240
+ }
241
+
242
+ if (!anyHovered) {
243
+ this.pointerDown = null;
244
+ this.overIndex = null;
245
+ }
246
+ } else {
247
+ // Draw individual
248
+ const scaleX = dw / w;
249
+ const scaleY = dh / h;
250
+ const scale = Math.min(scaleX, scaleY, 1);
251
+
252
+ w *= scale;
253
+ h *= scale;
254
+
255
+ let x = (dw - w) / 2;
256
+ let y = (dh - h) / 2 + shiftY;
257
+ ctx.drawImage(this.imgs[imageIndex], x, y, w, h);
258
+
259
+ const drawButton = (x, y, sz, text) => {
260
+ const hovered = LiteGraph.isInsideRectangle(mouse[0], mouse[1], x + this.pos[0], y + this.pos[1], sz, sz);
261
+ let fill = "#333";
262
+ let textFill = "#fff";
263
+ let isClicking = false;
264
+ if (hovered) {
265
+ canvas.canvas.style.cursor = "pointer";
266
+ if (canvas.pointer_is_down) {
267
+ fill = "#1e90ff";
268
+ isClicking = true;
269
+ } else {
270
+ fill = "#eee";
271
+ textFill = "#000";
272
+ }
273
+ } else {
274
+ this.pointerWasDown = null;
275
+ }
276
+
277
+ ctx.fillStyle = fill;
278
+ ctx.beginPath();
279
+ ctx.roundRect(x, y, sz, sz, [4]);
280
+ ctx.fill();
281
+ ctx.fillStyle = textFill;
282
+ ctx.font = "12px Arial";
283
+ ctx.textAlign = "center";
284
+ ctx.fillText(text, x + 15, y + 20);
285
+
286
+ return isClicking;
287
+ };
288
+
289
+ if (numImages > 1) {
290
+ if (drawButton(x + w - 35, y + h - 35, 30, `${this.imageIndex + 1}/${numImages}`)) {
291
+ let i = this.imageIndex + 1 >= numImages ? 0 : this.imageIndex + 1;
292
+ if (!this.pointerDown || !this.pointerDown.index === i) {
293
+ this.pointerDown = { index: i, pos: [...mouse] };
294
+ }
295
+ }
296
+
297
+ if (drawButton(x + w - 35, y + 5, 30, `x`)) {
298
+ if (!this.pointerDown || !this.pointerDown.index === null) {
299
+ this.pointerDown = { index: null, pos: [...mouse] };
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Adds a handler allowing drag+drop of files onto the window to load workflows
311
+ */
312
+ #addDropHandler() {
313
+ // Get prompt from dropped PNG or json
314
+ document.addEventListener("drop", async (event) => {
315
+ event.preventDefault();
316
+ event.stopPropagation();
317
+
318
+ const n = this.dragOverNode;
319
+ this.dragOverNode = null;
320
+ // Node handles file drop, we dont use the built in onDropFile handler as its buggy
321
+ // If you drag multiple files it will call it multiple times with the same file
322
+ if (n && n.onDragDrop && (await n.onDragDrop(event))) {
323
+ return;
324
+ }
325
+
326
+ await this.handleFile(event.dataTransfer.files[0]);
327
+ });
328
+
329
+ // Always clear over node on drag leave
330
+ this.canvasEl.addEventListener("dragleave", async () => {
331
+ if (this.dragOverNode) {
332
+ this.dragOverNode = null;
333
+ this.graph.setDirtyCanvas(false, true);
334
+ }
335
+ });
336
+
337
+ // Add handler for dropping onto a specific node
338
+ this.canvasEl.addEventListener(
339
+ "dragover",
340
+ (e) => {
341
+ this.canvas.adjustMouseEvent(e);
342
+ const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY);
343
+ if (node) {
344
+ if (node.onDragOver && node.onDragOver(e)) {
345
+ this.dragOverNode = node;
346
+
347
+ // dragover event is fired very frequently, run this on an animation frame
348
+ requestAnimationFrame(() => {
349
+ this.graph.setDirtyCanvas(false, true);
350
+ });
351
+ return;
352
+ }
353
+ }
354
+ this.dragOverNode = null;
355
+ },
356
+ false
357
+ );
358
+ }
359
+
360
+ /**
361
+ * Adds a handler on paste that extracts and loads workflows from pasted JSON data
362
+ */
363
+ #addPasteHandler() {
364
+ document.addEventListener("paste", (e) => {
365
+ let data = (e.clipboardData || window.clipboardData).getData("text/plain");
366
+ let workflow;
367
+ try {
368
+ data = data.slice(data.indexOf("{"));
369
+ workflow = JSON.parse(data);
370
+ } catch (err) {
371
+ try {
372
+ data = data.slice(data.indexOf("workflow\n"));
373
+ data = data.slice(data.indexOf("{"));
374
+ workflow = JSON.parse(data);
375
+ } catch (error) {}
376
+ }
377
+
378
+ if (workflow && workflow.version && workflow.nodes && workflow.extra) {
379
+ this.loadGraphData(workflow);
380
+ }
381
+ });
382
+ }
383
+
384
+ /**
385
+ * Handle mouse
386
+ *
387
+ * Move group by header
388
+ */
389
+ #addProcessMouseHandler() {
390
+ const self = this;
391
+
392
+ const origProcessMouseDown = LGraphCanvas.prototype.processMouseDown;
393
+ LGraphCanvas.prototype.processMouseDown = function(e) {
394
+ const res = origProcessMouseDown.apply(this, arguments);
395
+
396
+ this.selected_group_moving = false;
397
+
398
+ if (this.selected_group && !this.selected_group_resizing) {
399
+ var font_size =
400
+ this.selected_group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE;
401
+ var height = font_size * 1.4;
402
+
403
+ // Move group by header
404
+ if (LiteGraph.isInsideRectangle(e.canvasX, e.canvasY, this.selected_group.pos[0], this.selected_group.pos[1], this.selected_group.size[0], height)) {
405
+ this.selected_group_moving = true;
406
+ }
407
+ }
408
+
409
+ return res;
410
+ }
411
+
412
+ const origProcessMouseMove = LGraphCanvas.prototype.processMouseMove;
413
+ LGraphCanvas.prototype.processMouseMove = function(e) {
414
+ const orig_selected_group = this.selected_group;
415
+
416
+ if (this.selected_group && !this.selected_group_resizing && !this.selected_group_moving) {
417
+ this.selected_group = null;
418
+ }
419
+
420
+ const res = origProcessMouseMove.apply(this, arguments);
421
+
422
+ if (orig_selected_group && !this.selected_group_resizing && !this.selected_group_moving) {
423
+ this.selected_group = orig_selected_group;
424
+ }
425
+
426
+ return res;
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Handle keypress
432
+ *
433
+ * Ctrl + M mute/unmute selected nodes
434
+ */
435
+ #addProcessKeyHandler() {
436
+ const self = this;
437
+ const origProcessKey = LGraphCanvas.prototype.processKey;
438
+ LGraphCanvas.prototype.processKey = function(e) {
439
+ const res = origProcessKey.apply(this, arguments);
440
+
441
+ if (res === false) {
442
+ return res;
443
+ }
444
+
445
+ if (!this.graph) {
446
+ return;
447
+ }
448
+
449
+ var block_default = false;
450
+
451
+ if (e.target.localName == "input") {
452
+ return;
453
+ }
454
+
455
+ if (e.type == "keydown") {
456
+ // Ctrl + M mute/unmute
457
+ if (e.keyCode == 77 && e.ctrlKey) {
458
+ if (this.selected_nodes) {
459
+ for (var i in this.selected_nodes) {
460
+ if (this.selected_nodes[i].mode === 2) { // never
461
+ this.selected_nodes[i].mode = 0; // always
462
+ } else {
463
+ this.selected_nodes[i].mode = 2; // never
464
+ }
465
+ }
466
+ }
467
+ block_default = true;
468
+ }
469
+ }
470
+
471
+ this.graph.change();
472
+
473
+ if (block_default) {
474
+ e.preventDefault();
475
+ e.stopImmediatePropagation();
476
+ return false;
477
+ }
478
+
479
+ return res;
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Draws group header bar
485
+ */
486
+ #addDrawGroupsHandler() {
487
+ const self = this;
488
+
489
+ const origDrawGroups = LGraphCanvas.prototype.drawGroups;
490
+ LGraphCanvas.prototype.drawGroups = function(canvas, ctx) {
491
+ if (!this.graph) {
492
+ return;
493
+ }
494
+
495
+ var groups = this.graph._groups;
496
+
497
+ ctx.save();
498
+ ctx.globalAlpha = 0.7 * this.editor_alpha;
499
+
500
+ for (var i = 0; i < groups.length; ++i) {
501
+ var group = groups[i];
502
+
503
+ if (!LiteGraph.overlapBounding(this.visible_area, group._bounding)) {
504
+ continue;
505
+ } //out of the visible area
506
+
507
+ ctx.fillStyle = group.color || "#335";
508
+ ctx.strokeStyle = group.color || "#335";
509
+ var pos = group._pos;
510
+ var size = group._size;
511
+ ctx.globalAlpha = 0.25 * this.editor_alpha;
512
+ ctx.beginPath();
513
+ var font_size =
514
+ group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE;
515
+ ctx.rect(pos[0] + 0.5, pos[1] + 0.5, size[0], font_size * 1.4);
516
+ ctx.fill();
517
+ ctx.globalAlpha = this.editor_alpha;
518
+ }
519
+
520
+ ctx.restore();
521
+
522
+ const res = origDrawGroups.apply(this, arguments);
523
+ return res;
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Draws node highlights (executing, drag drop) and progress bar
529
+ */
530
+ #addDrawNodeHandler() {
531
+ const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape;
532
+ const self = this;
533
+
534
+ LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) {
535
+ const res = origDrawNodeShape.apply(this, arguments);
536
+
537
+ let color = null;
538
+ if (node.id === +self.runningNodeId) {
539
+ color = "#0f0";
540
+ } else if (self.dragOverNode && node.id === self.dragOverNode.id) {
541
+ color = "dodgerblue";
542
+ }
543
+
544
+ if (color) {
545
+ const shape = node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE;
546
+ ctx.lineWidth = 1;
547
+ ctx.globalAlpha = 0.8;
548
+ ctx.beginPath();
549
+ if (shape == LiteGraph.BOX_SHAPE)
550
+ ctx.rect(-6, -6 + LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT);
551
+ else if (shape == LiteGraph.ROUND_SHAPE || (shape == LiteGraph.CARD_SHAPE && node.flags.collapsed))
552
+ ctx.roundRect(
553
+ -6,
554
+ -6 - LiteGraph.NODE_TITLE_HEIGHT,
555
+ 12 + size[0] + 1,
556
+ 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
557
+ this.round_radius * 2
558
+ );
559
+ else if (shape == LiteGraph.CARD_SHAPE)
560
+ ctx.roundRect(
561
+ -6,
562
+ -6 + LiteGraph.NODE_TITLE_HEIGHT,
563
+ 12 + size[0] + 1,
564
+ 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
565
+ this.round_radius * 2,
566
+ 2
567
+ );
568
+ else if (shape == LiteGraph.CIRCLE_SHAPE)
569
+ ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2);
570
+ ctx.strokeStyle = color;
571
+ ctx.stroke();
572
+ ctx.strokeStyle = fgcolor;
573
+ ctx.globalAlpha = 1;
574
+
575
+ if (self.progress) {
576
+ ctx.fillStyle = "green";
577
+ ctx.fillRect(0, 0, size[0] * (self.progress.value / self.progress.max), 6);
578
+ ctx.fillStyle = bgcolor;
579
+ }
580
+ }
581
+
582
+ return res;
583
+ };
584
+
585
+ const origDrawNode = LGraphCanvas.prototype.drawNode;
586
+ LGraphCanvas.prototype.drawNode = function (node, ctx) {
587
+ var editor_alpha = this.editor_alpha;
588
+
589
+ if (node.mode === 2) { // never
590
+ this.editor_alpha = 0.4;
591
+ }
592
+
593
+ const res = origDrawNode.apply(this, arguments);
594
+
595
+ this.editor_alpha = editor_alpha;
596
+
597
+ return res;
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Handles updates from the API socket
603
+ */
604
+ #addApiUpdateHandlers() {
605
+ api.addEventListener("status", ({ detail }) => {
606
+ this.ui.setStatus(detail);
607
+ });
608
+
609
+ api.addEventListener("reconnecting", () => {
610
+ this.ui.dialog.show("Reconnecting...");
611
+ });
612
+
613
+ api.addEventListener("reconnected", () => {
614
+ this.ui.dialog.close();
615
+ });
616
+
617
+ api.addEventListener("progress", ({ detail }) => {
618
+ this.progress = detail;
619
+ this.graph.setDirtyCanvas(true, false);
620
+ });
621
+
622
+ api.addEventListener("executing", ({ detail }) => {
623
+ this.progress = null;
624
+ this.runningNodeId = detail;
625
+ this.graph.setDirtyCanvas(true, false);
626
+ });
627
+
628
+ api.addEventListener("executed", ({ detail }) => {
629
+ this.nodeOutputs[detail.node] = detail.output;
630
+ const node = this.graph.getNodeById(detail.node);
631
+ if (node?.onExecuted) {
632
+ node.onExecuted(detail.output);
633
+ }
634
+ });
635
+
636
+ api.init();
637
+ }
638
+
639
+ #addKeyboardHandler() {
640
+ window.addEventListener("keydown", (e) => {
641
+ this.shiftDown = e.shiftKey;
642
+
643
+ // Queue prompt using ctrl or command + enter
644
+ if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.keyCode === 13 || e.keyCode === 10)) {
645
+ this.queuePrompt(e.shiftKey ? -1 : 0);
646
+ }
647
+ });
648
+ window.addEventListener("keyup", (e) => {
649
+ this.shiftDown = e.shiftKey;
650
+ });
651
+ }
652
+
653
+ /**
654
+ * Loads all extensions from the API into the window
655
+ */
656
+ async #loadExtensions() {
657
+ const extensions = await api.getExtensions();
658
+ for (const ext of extensions) {
659
+ try {
660
+ await import(ext);
661
+ } catch (error) {
662
+ console.error("Error loading extension", ext, error);
663
+ }
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Set up the app on the page
669
+ */
670
+ async setup() {
671
+ await this.#loadExtensions();
672
+
673
+ // Create and mount the LiteGraph in the DOM
674
+ const canvasEl = (this.canvasEl = Object.assign(document.createElement("canvas"), { id: "graph-canvas" }));
675
+ canvasEl.tabIndex = "1";
676
+ document.body.prepend(canvasEl);
677
+
678
+ this.#addProcessMouseHandler();
679
+ this.#addProcessKeyHandler();
680
+
681
+ this.graph = new LGraph();
682
+ const canvas = (this.canvas = new LGraphCanvas(canvasEl, this.graph));
683
+ this.ctx = canvasEl.getContext("2d");
684
+
685
+ LiteGraph.release_link_on_empty_shows_menu = true;
686
+ LiteGraph.alt_drag_do_clone_nodes = true;
687
+
688
+ this.graph.start();
689
+
690
+ function resizeCanvas() {
691
+ canvasEl.width = canvasEl.offsetWidth;
692
+ canvasEl.height = canvasEl.offsetHeight;
693
+ canvas.draw(true, true);
694
+ }
695
+
696
+ // Ensure the canvas fills the window
697
+ resizeCanvas();
698
+ window.addEventListener("resize", resizeCanvas);
699
+
700
+ await this.#invokeExtensionsAsync("init");
701
+ await this.registerNodes();
702
+
703
+ // Load previous workflow
704
+ let restored = false;
705
+ try {
706
+ const json = localStorage.getItem("workflow");
707
+ if (json) {
708
+ const workflow = JSON.parse(json);
709
+ this.loadGraphData(workflow);
710
+ restored = true;
711
+ }
712
+ } catch (err) {
713
+ console.error("Error loading previous workflow", err);
714
+ }
715
+
716
+ // We failed to restore a workflow so load the default
717
+ if (!restored) {
718
+ this.loadGraphData();
719
+ }
720
+
721
+ // Save current workflow automatically
722
+ setInterval(() => localStorage.setItem("workflow", JSON.stringify(this.graph.serialize())), 1000);
723
+
724
+ this.#addDrawNodeHandler();
725
+ this.#addDrawGroupsHandler();
726
+ this.#addApiUpdateHandlers();
727
+ this.#addDropHandler();
728
+ this.#addPasteHandler();
729
+ this.#addKeyboardHandler();
730
+
731
+ await this.#invokeExtensionsAsync("setup");
732
+ }
733
+
734
+ /**
735
+ * Registers nodes with the graph
736
+ */
737
+ async registerNodes() {
738
+ const app = this;
739
+ // Load node definitions from the backend
740
+ const defs = await api.getNodeDefs();
741
+ await this.#invokeExtensionsAsync("addCustomNodeDefs", defs);
742
+
743
+ // Generate list of known widgets
744
+ const widgets = Object.assign(
745
+ {},
746
+ ComfyWidgets,
747
+ ...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean)
748
+ );
749
+
750
+ // Register a node for each definition
751
+ for (const nodeId in defs) {
752
+ const nodeData = defs[nodeId];
753
+ const node = Object.assign(
754
+ function ComfyNode() {
755
+ var inputs = nodeData["input"]["required"];
756
+ if (nodeData["input"]["optional"] != undefined){
757
+ inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
758
+ }
759
+ const config = { minWidth: 1, minHeight: 1 };
760
+ for (const inputName in inputs) {
761
+ const inputData = inputs[inputName];
762
+ const type = inputData[0];
763
+
764
+ if(inputData[1]?.forceInput) {
765
+ this.addInput(inputName, type);
766
+ } else {
767
+ if (Array.isArray(type)) {
768
+ // Enums
769
+ Object.assign(config, widgets.COMBO(this, inputName, inputData, app) || {});
770
+ } else if (`${type}:${inputName}` in widgets) {
771
+ // Support custom widgets by Type:Name
772
+ Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {});
773
+ } else if (type in widgets) {
774
+ // Standard type widgets
775
+ Object.assign(config, widgets[type](this, inputName, inputData, app) || {});
776
+ } else {
777
+ // Node connection inputs
778
+ this.addInput(inputName, type);
779
+ }
780
+ }
781
+ }
782
+
783
+ for (const o in nodeData["output"]) {
784
+ const output = nodeData["output"][o];
785
+ const outputName = nodeData["output_name"][o] || output;
786
+ this.addOutput(outputName, output);
787
+ }
788
+
789
+ const s = this.computeSize();
790
+ s[0] = Math.max(config.minWidth, s[0] * 1.5);
791
+ s[1] = Math.max(config.minHeight, s[1]);
792
+ this.size = s;
793
+ this.serialize_widgets = true;
794
+
795
+ app.#invokeExtensionsAsync("nodeCreated", this);
796
+ },
797
+ {
798
+ title: nodeData.name,
799
+ comfyClass: nodeData.name,
800
+ }
801
+ );
802
+ node.prototype.comfyClass = nodeData.name;
803
+
804
+ this.#addNodeContextMenuHandler(node);
805
+ this.#addDrawBackgroundHandler(node, app);
806
+
807
+ await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
808
+ LiteGraph.registerNodeType(nodeId, node);
809
+ node.category = nodeData.category;
810
+ }
811
+
812
+ await this.#invokeExtensionsAsync("registerCustomNodes");
813
+ }
814
+
815
+ /**
816
+ * Populates the graph with the specified workflow data
817
+ * @param {*} graphData A serialized graph object
818
+ */
819
+ loadGraphData(graphData) {
820
+ this.clean();
821
+
822
+ if (!graphData) {
823
+ graphData = structuredClone(defaultGraph);
824
+ }
825
+
826
+ // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
827
+ for (let n of graphData.nodes) {
828
+ if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
829
+ }
830
+
831
+ this.graph.configure(graphData);
832
+
833
+ for (const node of this.graph._nodes) {
834
+ const size = node.computeSize();
835
+ size[0] = Math.max(node.size[0], size[0]);
836
+ size[1] = Math.max(node.size[1], size[1]);
837
+ node.size = size;
838
+
839
+ if (node.widgets) {
840
+ // If you break something in the backend and want to patch workflows in the frontend
841
+ // This is the place to do this
842
+ for (let widget of node.widgets) {
843
+ if (node.type == "KSampler" || node.type == "KSamplerAdvanced") {
844
+ if (widget.name == "sampler_name") {
845
+ if (widget.value.startsWith("sample_")) {
846
+ widget.value = widget.value.slice(7);
847
+ }
848
+ }
849
+ }
850
+ }
851
+ }
852
+
853
+ this.#invokeExtensions("loadedGraphNode", node);
854
+ }
855
+ }
856
+
857
+ /**
858
+ * Converts the current graph workflow for sending to the API
859
+ * @returns The workflow and node links
860
+ */
861
+ async graphToPrompt() {
862
+ const workflow = this.graph.serialize();
863
+ const output = {};
864
+ // Process nodes in order of execution
865
+ for (const node of this.graph.computeExecutionOrder(false)) {
866
+ const n = workflow.nodes.find((n) => n.id === node.id);
867
+
868
+ if (node.isVirtualNode) {
869
+ // Don't serialize frontend only nodes but let them make changes
870
+ if (node.applyToGraph) {
871
+ node.applyToGraph(workflow);
872
+ }
873
+ continue;
874
+ }
875
+
876
+ if (node.mode === 2) {
877
+ // Don't serialize muted nodes
878
+ continue;
879
+ }
880
+
881
+ const inputs = {};
882
+ const widgets = node.widgets;
883
+
884
+ // Store all widget values
885
+ if (widgets) {
886
+ for (const i in widgets) {
887
+ const widget = widgets[i];
888
+ if (!widget.options || widget.options.serialize !== false) {
889
+ inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(n, i) : widget.value;
890
+ }
891
+ }
892
+ }
893
+
894
+ // Store all node links
895
+ for (let i in node.inputs) {
896
+ let parent = node.getInputNode(i);
897
+ if (parent) {
898
+ let link = node.getInputLink(i);
899
+ while (parent && parent.isVirtualNode) {
900
+ link = parent.getInputLink(link.origin_slot);
901
+ if (link) {
902
+ parent = parent.getInputNode(link.origin_slot);
903
+ } else {
904
+ parent = null;
905
+ }
906
+ }
907
+
908
+ if (link) {
909
+ inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)];
910
+ }
911
+ }
912
+ }
913
+
914
+ output[String(node.id)] = {
915
+ inputs,
916
+ class_type: node.comfyClass,
917
+ };
918
+ }
919
+
920
+ // Remove inputs connected to removed nodes
921
+
922
+ for (const o in output) {
923
+ for (const i in output[o].inputs) {
924
+ if (Array.isArray(output[o].inputs[i])
925
+ && output[o].inputs[i].length === 2
926
+ && !output[output[o].inputs[i][0]]) {
927
+ delete output[o].inputs[i];
928
+ }
929
+ }
930
+ }
931
+
932
+ return { workflow, output };
933
+ }
934
+
935
+ async queuePrompt(number, batchCount = 1) {
936
+ this.#queueItems.push({ number, batchCount });
937
+
938
+ // Only have one action process the items so each one gets a unique seed correctly
939
+ if (this.#processingQueue) {
940
+ return;
941
+ }
942
+
943
+ this.#processingQueue = true;
944
+ try {
945
+ while (this.#queueItems.length) {
946
+ ({ number, batchCount } = this.#queueItems.pop());
947
+
948
+ for (let i = 0; i < batchCount; i++) {
949
+ const p = await this.graphToPrompt();
950
+
951
+ try {
952
+ await api.queuePrompt(number, p);
953
+ } catch (error) {
954
+ this.ui.dialog.show(error.response || error.toString());
955
+ break;
956
+ }
957
+
958
+ for (const n of p.workflow.nodes) {
959
+ const node = graph.getNodeById(n.id);
960
+ if (node.widgets) {
961
+ for (const widget of node.widgets) {
962
+ // Allow widgets to run callbacks after a prompt has been queued
963
+ // e.g. random seed after every gen
964
+ if (widget.afterQueued) {
965
+ widget.afterQueued();
966
+ }
967
+ }
968
+ }
969
+ }
970
+
971
+ this.canvas.draw(true, true);
972
+ await this.ui.queue.update();
973
+ }
974
+ }
975
+ } finally {
976
+ this.#processingQueue = false;
977
+ }
978
+ }
979
+
980
+ /**
981
+ * Loads workflow data from the specified file
982
+ * @param {File} file
983
+ */
984
+ async handleFile(file) {
985
+ if (file.type === "image/png") {
986
+ const pngInfo = await getPngMetadata(file);
987
+ if (pngInfo) {
988
+ if (pngInfo.workflow) {
989
+ this.loadGraphData(JSON.parse(pngInfo.workflow));
990
+ } else if (pngInfo.parameters) {
991
+ importA1111(this.graph, pngInfo.parameters);
992
+ }
993
+ }
994
+ } else if (file.type === "application/json" || file.name.endsWith(".json")) {
995
+ const reader = new FileReader();
996
+ reader.onload = () => {
997
+ this.loadGraphData(JSON.parse(reader.result));
998
+ };
999
+ reader.readAsText(file);
1000
+ }
1001
+ }
1002
+
1003
+ registerExtension(extension) {
1004
+ if (!extension.name) {
1005
+ throw new Error("Extensions must have a 'name' property.");
1006
+ }
1007
+ if (this.extensions.find((ext) => ext.name === extension.name)) {
1008
+ throw new Error(`Extension named '${extension.name}' already registered.`);
1009
+ }
1010
+ this.extensions.push(extension);
1011
+ }
1012
+
1013
+ /**
1014
+ * Refresh combo list on whole nodes
1015
+ */
1016
+ async refreshComboInNodes() {
1017
+ const defs = await api.getNodeDefs();
1018
+
1019
+ for(let nodeNum in this.graph._nodes) {
1020
+ const node = this.graph._nodes[nodeNum];
1021
+
1022
+ const def = defs[node.type];
1023
+
1024
+ for(const widgetNum in node.widgets) {
1025
+ const widget = node.widgets[widgetNum]
1026
+
1027
+ if(widget.type == "combo" && def["input"]["required"][widget.name] !== undefined) {
1028
+ widget.options.values = def["input"]["required"][widget.name][0];
1029
+
1030
+ if(!widget.options.values.includes(widget.value)) {
1031
+ widget.value = widget.options.values[0];
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Clean current state
1040
+ */
1041
+ clean() {
1042
+ this.nodeOutputs = {};
1043
+ }
1044
+ }
1045
+
1046
+ export const app = new ComfyApp();
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: ["bad hands"],
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: ["masterpiece best quality girl"],
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: [8566257, 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
+ };
web/scripts/pnginfo.js ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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") {
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 text = String.fromCharCode(...pngData.slice(keyword_end + 1, offset + 8 + length));
36
+ txt_chunks[keyword] = text;
37
+ }
38
+
39
+ offset += 12 + length;
40
+ }
41
+
42
+ r(txt_chunks);
43
+ };
44
+
45
+ reader.readAsArrayBuffer(file);
46
+ });
47
+ }
48
+
49
+ export async function importA1111(graph, parameters) {
50
+ const p = parameters.lastIndexOf("\nSteps:");
51
+ if (p > -1) {
52
+ const embeddings = await api.getEmbeddings();
53
+ const opts = parameters
54
+ .substr(p)
55
+ .split(",")
56
+ .reduce((p, n) => {
57
+ const s = n.split(":");
58
+ p[s[0].trim().toLowerCase()] = s[1].trim();
59
+ return p;
60
+ }, {});
61
+ const p2 = parameters.lastIndexOf("\nNegative prompt:", p);
62
+ if (p2 > -1) {
63
+ let positive = parameters.substr(0, p2).trim();
64
+ let negative = parameters.substring(p2 + 18, p).trim();
65
+
66
+ const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple");
67
+ const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer");
68
+ const positiveNode = LiteGraph.createNode("CLIPTextEncode");
69
+ const negativeNode = LiteGraph.createNode("CLIPTextEncode");
70
+ const samplerNode = LiteGraph.createNode("KSampler");
71
+ const imageNode = LiteGraph.createNode("EmptyLatentImage");
72
+ const vaeNode = LiteGraph.createNode("VAEDecode");
73
+ const vaeLoaderNode = LiteGraph.createNode("VAELoader");
74
+ const saveNode = LiteGraph.createNode("SaveImage");
75
+ let hrSamplerNode = null;
76
+
77
+ const ceil64 = (v) => Math.ceil(v / 64) * 64;
78
+
79
+ function getWidget(node, name) {
80
+ return node.widgets.find((w) => w.name === name);
81
+ }
82
+
83
+ function setWidgetValue(node, name, value, isOptionPrefix) {
84
+ const w = getWidget(node, name);
85
+ if (isOptionPrefix) {
86
+ const o = w.options.values.find((w) => w.startsWith(value));
87
+ if (o) {
88
+ w.value = o;
89
+ } else {
90
+ console.warn(`Unknown value '${value}' for widget '${name}'`, node);
91
+ w.value = value;
92
+ }
93
+ } else {
94
+ w.value = value;
95
+ }
96
+ }
97
+
98
+ function createLoraNodes(clipNode, text, prevClip, prevModel) {
99
+ const loras = [];
100
+ text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) {
101
+ const s = c.split(":");
102
+ const weight = parseFloat(s[1]);
103
+ if (isNaN(weight)) {
104
+ console.warn("Invalid LORA", m);
105
+ } else {
106
+ loras.push({ name: s[0], weight });
107
+ }
108
+ return "";
109
+ });
110
+
111
+ for (const l of loras) {
112
+ const loraNode = LiteGraph.createNode("LoraLoader");
113
+ graph.add(loraNode);
114
+ setWidgetValue(loraNode, "lora_name", l.name, true);
115
+ setWidgetValue(loraNode, "strength_model", l.weight);
116
+ setWidgetValue(loraNode, "strength_clip", l.weight);
117
+ prevModel.node.connect(prevModel.index, loraNode, 0);
118
+ prevClip.node.connect(prevClip.index, loraNode, 1);
119
+ prevModel = { node: loraNode, index: 0 };
120
+ prevClip = { node: loraNode, index: 1 };
121
+ }
122
+
123
+ prevClip.node.connect(1, clipNode, 0);
124
+ prevModel.node.connect(0, samplerNode, 0);
125
+ if (hrSamplerNode) {
126
+ prevModel.node.connect(0, hrSamplerNode, 0);
127
+ }
128
+
129
+ return { text, prevModel, prevClip };
130
+ }
131
+
132
+ function replaceEmbeddings(text) {
133
+ return text.replaceAll(
134
+ new RegExp(
135
+ "\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b",
136
+ "ig"
137
+ ),
138
+ "embedding:$1"
139
+ );
140
+ }
141
+
142
+ function popOpt(name) {
143
+ const v = opts[name];
144
+ delete opts[name];
145
+ return v;
146
+ }
147
+
148
+ graph.clear();
149
+ graph.add(ckptNode);
150
+ graph.add(clipSkipNode);
151
+ graph.add(positiveNode);
152
+ graph.add(negativeNode);
153
+ graph.add(samplerNode);
154
+ graph.add(imageNode);
155
+ graph.add(vaeNode);
156
+ graph.add(vaeLoaderNode);
157
+ graph.add(saveNode);
158
+
159
+ ckptNode.connect(1, clipSkipNode, 0);
160
+ clipSkipNode.connect(0, positiveNode, 0);
161
+ clipSkipNode.connect(0, negativeNode, 0);
162
+ ckptNode.connect(0, samplerNode, 0);
163
+ positiveNode.connect(0, samplerNode, 1);
164
+ negativeNode.connect(0, samplerNode, 2);
165
+ imageNode.connect(0, samplerNode, 3);
166
+ vaeNode.connect(0, saveNode, 0);
167
+ samplerNode.connect(0, vaeNode, 0);
168
+ vaeLoaderNode.connect(0, vaeNode, 1);
169
+
170
+ const handlers = {
171
+ model(v) {
172
+ setWidgetValue(ckptNode, "ckpt_name", v, true);
173
+ },
174
+ "cfg scale"(v) {
175
+ setWidgetValue(samplerNode, "cfg", +v);
176
+ },
177
+ "clip skip"(v) {
178
+ setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v);
179
+ },
180
+ sampler(v) {
181
+ let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_");
182
+ if (name.includes("karras")) {
183
+ name = name.replace("karras", "").replace(/_+$/, "");
184
+ setWidgetValue(samplerNode, "scheduler", "karras");
185
+ } else {
186
+ setWidgetValue(samplerNode, "scheduler", "normal");
187
+ }
188
+ const w = getWidget(samplerNode, "sampler_name");
189
+ const o = w.options.values.find((w) => w === name || w === "sample_" + name);
190
+ if (o) {
191
+ setWidgetValue(samplerNode, "sampler_name", o);
192
+ }
193
+ },
194
+ size(v) {
195
+ const wxh = v.split("x");
196
+ const w = ceil64(+wxh[0]);
197
+ const h = ceil64(+wxh[1]);
198
+ const hrUp = popOpt("hires upscale");
199
+ const hrSz = popOpt("hires resize");
200
+ let hrMethod = popOpt("hires upscaler");
201
+
202
+ setWidgetValue(imageNode, "width", w);
203
+ setWidgetValue(imageNode, "height", h);
204
+
205
+ if (hrUp || hrSz) {
206
+ let uw, uh;
207
+ if (hrUp) {
208
+ uw = w * hrUp;
209
+ uh = h * hrUp;
210
+ } else {
211
+ const s = hrSz.split("x");
212
+ uw = +s[0];
213
+ uh = +s[1];
214
+ }
215
+
216
+ let upscaleNode;
217
+ let latentNode;
218
+
219
+ if (hrMethod.startsWith("Latent")) {
220
+ latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale");
221
+ graph.add(upscaleNode);
222
+ samplerNode.connect(0, upscaleNode, 0);
223
+
224
+ switch (hrMethod) {
225
+ case "Latent (nearest-exact)":
226
+ hrMethod = "nearest-exact";
227
+ break;
228
+ }
229
+ setWidgetValue(upscaleNode, "upscale_method", hrMethod, true);
230
+ } else {
231
+ const decode = LiteGraph.createNode("VAEDecodeTiled");
232
+ graph.add(decode);
233
+ samplerNode.connect(0, decode, 0);
234
+ vaeLoaderNode.connect(0, decode, 1);
235
+
236
+ const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader");
237
+ graph.add(upscaleLoaderNode);
238
+ setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true);
239
+
240
+ const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel");
241
+ graph.add(modelUpscaleNode);
242
+ decode.connect(0, modelUpscaleNode, 1);
243
+ upscaleLoaderNode.connect(0, modelUpscaleNode, 0);
244
+
245
+ upscaleNode = LiteGraph.createNode("ImageScale");
246
+ graph.add(upscaleNode);
247
+ modelUpscaleNode.connect(0, upscaleNode, 0);
248
+
249
+ const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled"));
250
+ graph.add(vaeEncodeNode);
251
+ upscaleNode.connect(0, vaeEncodeNode, 0);
252
+ vaeLoaderNode.connect(0, vaeEncodeNode, 1);
253
+ }
254
+
255
+ setWidgetValue(upscaleNode, "width", ceil64(uw));
256
+ setWidgetValue(upscaleNode, "height", ceil64(uh));
257
+
258
+ hrSamplerNode = LiteGraph.createNode("KSampler");
259
+ graph.add(hrSamplerNode);
260
+ ckptNode.connect(0, hrSamplerNode, 0);
261
+ positiveNode.connect(0, hrSamplerNode, 1);
262
+ negativeNode.connect(0, hrSamplerNode, 2);
263
+ latentNode.connect(0, hrSamplerNode, 3);
264
+ hrSamplerNode.connect(0, vaeNode, 0);
265
+ }
266
+ },
267
+ steps(v) {
268
+ setWidgetValue(samplerNode, "steps", +v);
269
+ },
270
+ seed(v) {
271
+ setWidgetValue(samplerNode, "seed", +v);
272
+ },
273
+ };
274
+
275
+ for (const opt in opts) {
276
+ if (opt in handlers) {
277
+ handlers[opt](popOpt(opt));
278
+ }
279
+ }
280
+
281
+ if (hrSamplerNode) {
282
+ setWidgetValue(hrSamplerNode, "steps", getWidget(samplerNode, "steps").value);
283
+ setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value);
284
+ setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value);
285
+ setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value);
286
+ setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1"));
287
+ }
288
+
289
+ let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 });
290
+ positive = n.text;
291
+ n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel);
292
+ negative = n.text;
293
+
294
+ setWidgetValue(positiveNode, "text", replaceEmbeddings(positive));
295
+ setWidgetValue(negativeNode, "text", replaceEmbeddings(negative));
296
+
297
+ graph.arrange();
298
+
299
+ for (const opt of ["model hash", "ensd"]) {
300
+ delete opts[opt];
301
+ }
302
+
303
+ console.warn("Unhandled parameters:", opts);
304
+ }
305
+ }
306
+ }
web/scripts/ui.js ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "./api.js";
2
+
3
+ export function $el(tag, propsOrChildren, children) {
4
+ const split = tag.split(".");
5
+ const element = document.createElement(split.shift());
6
+ element.classList.add(...split);
7
+ if (propsOrChildren) {
8
+ if (Array.isArray(propsOrChildren)) {
9
+ element.append(...propsOrChildren);
10
+ } else {
11
+ const parent = propsOrChildren.parent;
12
+ delete propsOrChildren.parent;
13
+ const cb = propsOrChildren.$;
14
+ delete propsOrChildren.$;
15
+
16
+ if (propsOrChildren.style) {
17
+ Object.assign(element.style, propsOrChildren.style);
18
+ delete propsOrChildren.style;
19
+ }
20
+
21
+ Object.assign(element, propsOrChildren);
22
+ if (children) {
23
+ element.append(...children);
24
+ }
25
+
26
+ if (parent) {
27
+ parent.append(element);
28
+ }
29
+
30
+ if (cb) {
31
+ cb(element);
32
+ }
33
+ }
34
+ }
35
+ return element;
36
+ }
37
+
38
+ function dragElement(dragEl, settings) {
39
+ var posDiffX = 0,
40
+ posDiffY = 0,
41
+ posStartX = 0,
42
+ posStartY = 0,
43
+ newPosX = 0,
44
+ newPosY = 0;
45
+ if (dragEl.getElementsByClassName("drag-handle")[0]) {
46
+ // if present, the handle is where you move the DIV from:
47
+ dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown;
48
+ } else {
49
+ // otherwise, move the DIV from anywhere inside the DIV:
50
+ dragEl.onmousedown = dragMouseDown;
51
+ }
52
+
53
+ // When the element resizes (e.g. view queue) ensure it is still in the windows bounds
54
+ const resizeObserver = new ResizeObserver(() => {
55
+ ensureInBounds();
56
+ }).observe(dragEl);
57
+
58
+ function ensureInBounds() {
59
+ if (dragEl.classList.contains("comfy-menu-manual-pos")) {
60
+ newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft));
61
+ newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop));
62
+
63
+ positionElement();
64
+ }
65
+ }
66
+
67
+ function positionElement() {
68
+ const halfWidth = document.body.clientWidth / 2;
69
+ const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
70
+
71
+ // set the element's new position:
72
+ if (anchorRight) {
73
+ dragEl.style.left = "unset";
74
+ dragEl.style.right = document.body.clientWidth - newPosX - dragEl.clientWidth + "px";
75
+ } else {
76
+ dragEl.style.left = newPosX + "px";
77
+ dragEl.style.right = "unset";
78
+ }
79
+
80
+ dragEl.style.top = newPosY + "px";
81
+ dragEl.style.bottom = "unset";
82
+
83
+ if (savePos) {
84
+ localStorage.setItem(
85
+ "Comfy.MenuPosition",
86
+ JSON.stringify({
87
+ x: dragEl.offsetLeft,
88
+ y: dragEl.offsetTop,
89
+ })
90
+ );
91
+ }
92
+ }
93
+
94
+ function restorePos() {
95
+ let pos = localStorage.getItem("Comfy.MenuPosition");
96
+ if (pos) {
97
+ pos = JSON.parse(pos);
98
+ newPosX = pos.x;
99
+ newPosY = pos.y;
100
+ positionElement();
101
+ ensureInBounds();
102
+ }
103
+ }
104
+
105
+ let savePos = undefined;
106
+ settings.addSetting({
107
+ id: "Comfy.MenuPosition",
108
+ name: "Save menu position",
109
+ type: "boolean",
110
+ defaultValue: savePos,
111
+ onChange(value) {
112
+ if (savePos === undefined && value) {
113
+ restorePos();
114
+ }
115
+ savePos = value;
116
+ },
117
+ });
118
+
119
+ function dragMouseDown(e) {
120
+ e = e || window.event;
121
+ e.preventDefault();
122
+ // get the mouse cursor position at startup:
123
+ posStartX = e.clientX;
124
+ posStartY = e.clientY;
125
+ document.onmouseup = closeDragElement;
126
+ // call a function whenever the cursor moves:
127
+ document.onmousemove = elementDrag;
128
+ }
129
+
130
+ function elementDrag(e) {
131
+ e = e || window.event;
132
+ e.preventDefault();
133
+
134
+ dragEl.classList.add("comfy-menu-manual-pos");
135
+
136
+ // calculate the new cursor position:
137
+ posDiffX = e.clientX - posStartX;
138
+ posDiffY = e.clientY - posStartY;
139
+ posStartX = e.clientX;
140
+ posStartY = e.clientY;
141
+
142
+ newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft + posDiffX));
143
+ newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop + posDiffY));
144
+
145
+ positionElement();
146
+ }
147
+
148
+ window.addEventListener("resize", () => {
149
+ ensureInBounds();
150
+ });
151
+
152
+ function closeDragElement() {
153
+ // stop moving when mouse button is released:
154
+ document.onmouseup = null;
155
+ document.onmousemove = null;
156
+ }
157
+ }
158
+
159
+ class ComfyDialog {
160
+ constructor() {
161
+ this.element = $el("div.comfy-modal", { parent: document.body }, [
162
+ $el("div.comfy-modal-content", [
163
+ $el("p", { $: (p) => (this.textElement = p) }),
164
+ $el("button", {
165
+ type: "button",
166
+ textContent: "CLOSE",
167
+ onclick: () => this.close(),
168
+ }),
169
+ ]),
170
+ ]);
171
+ }
172
+
173
+ close() {
174
+ this.element.style.display = "none";
175
+ }
176
+
177
+ show(html) {
178
+ this.textElement.innerHTML = html;
179
+ this.element.style.display = "flex";
180
+ }
181
+ }
182
+
183
+ class ComfySettingsDialog extends ComfyDialog {
184
+ constructor() {
185
+ super();
186
+ this.element.classList.add("comfy-settings");
187
+ this.settings = [];
188
+ }
189
+
190
+ getSettingValue(id, defaultValue) {
191
+ const settingId = "Comfy.Settings." + id;
192
+ const v = localStorage[settingId];
193
+ return v == null ? defaultValue : JSON.parse(v);
194
+ }
195
+
196
+ setSettingValue(id, value) {
197
+ const settingId = "Comfy.Settings." + id;
198
+ localStorage[settingId] = JSON.stringify(value);
199
+ }
200
+
201
+ addSetting({ id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", }) {
202
+ if (!id) {
203
+ throw new Error("Settings must have an ID");
204
+ }
205
+ if (this.settings.find((s) => s.id === id)) {
206
+ throw new Error("Setting IDs must be unique");
207
+ }
208
+
209
+ const settingId = "Comfy.Settings." + id;
210
+ const v = localStorage[settingId];
211
+ let value = v == null ? defaultValue : JSON.parse(v);
212
+
213
+ // Trigger initial setting of value
214
+ if (onChange) {
215
+ onChange(value, undefined);
216
+ }
217
+
218
+ this.settings.push({
219
+ render: () => {
220
+ const setter = (v) => {
221
+ if (onChange) {
222
+ onChange(v, value);
223
+ }
224
+ localStorage[settingId] = JSON.stringify(v);
225
+ value = v;
226
+ };
227
+
228
+ let element;
229
+
230
+ if (typeof type === "function") {
231
+ element = type(name, setter, value, attrs);
232
+ } else {
233
+ switch (type) {
234
+ case "boolean":
235
+ element = $el("div", [
236
+ $el("label", { textContent: name || id }, [
237
+ $el("input", {
238
+ type: "checkbox",
239
+ checked: !!value,
240
+ oninput: (e) => {
241
+ setter(e.target.checked);
242
+ },
243
+ ...attrs
244
+ }),
245
+ ]),
246
+ ]);
247
+ break;
248
+ case "number":
249
+ element = $el("div", [
250
+ $el("label", { textContent: name || id }, [
251
+ $el("input", {
252
+ type,
253
+ value,
254
+ oninput: (e) => {
255
+ setter(e.target.value);
256
+ },
257
+ ...attrs
258
+ }),
259
+ ]),
260
+ ]);
261
+ break;
262
+ default:
263
+ console.warn("Unsupported setting type, defaulting to text");
264
+ element = $el("div", [
265
+ $el("label", { textContent: name || id }, [
266
+ $el("input", {
267
+ value,
268
+ oninput: (e) => {
269
+ setter(e.target.value);
270
+ },
271
+ ...attrs
272
+ }),
273
+ ]),
274
+ ]);
275
+ break;
276
+ }
277
+ }
278
+ if(tooltip) {
279
+ element.title = tooltip;
280
+ }
281
+
282
+ return element;
283
+ },
284
+ });
285
+ }
286
+
287
+ show() {
288
+ super.show();
289
+ Object.assign(this.textElement.style, {
290
+ display: "flex",
291
+ flexDirection: "column",
292
+ gap: "10px"
293
+ });
294
+ this.textElement.replaceChildren(...this.settings.map((s) => s.render()));
295
+ }
296
+ }
297
+
298
+ class ComfyList {
299
+ #type;
300
+ #text;
301
+
302
+ constructor(text, type) {
303
+ this.#text = text;
304
+ this.#type = type || text.toLowerCase();
305
+ this.element = $el("div.comfy-list");
306
+ this.element.style.display = "none";
307
+ }
308
+
309
+ get visible() {
310
+ return this.element.style.display !== "none";
311
+ }
312
+
313
+ async load() {
314
+ const items = await api.getItems(this.#type);
315
+ this.element.replaceChildren(
316
+ ...Object.keys(items).flatMap((section) => [
317
+ $el("h4", {
318
+ textContent: section,
319
+ }),
320
+ $el("div.comfy-list-items", [
321
+ ...items[section].map((item) => {
322
+ // Allow items to specify a custom remove action (e.g. for interrupt current prompt)
323
+ const removeAction = item.remove || {
324
+ name: "Delete",
325
+ cb: () => api.deleteItem(this.#type, item.prompt[1]),
326
+ };
327
+ return $el("div", { textContent: item.prompt[0] + ": " }, [
328
+ $el("button", {
329
+ textContent: "Load",
330
+ onclick: () => {
331
+ app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
332
+ if (item.outputs) {
333
+ app.nodeOutputs = item.outputs;
334
+ }
335
+ },
336
+ }),
337
+ $el("button", {
338
+ textContent: removeAction.name,
339
+ onclick: async () => {
340
+ await removeAction.cb();
341
+ await this.update();
342
+ },
343
+ }),
344
+ ]);
345
+ }),
346
+ ]),
347
+ ]),
348
+ $el("div.comfy-list-actions", [
349
+ $el("button", {
350
+ textContent: "Clear " + this.#text,
351
+ onclick: async () => {
352
+ await api.clearItems(this.#type);
353
+ await this.load();
354
+ },
355
+ }),
356
+ $el("button", { textContent: "Refresh", onclick: () => this.load() }),
357
+ ])
358
+ );
359
+ }
360
+
361
+ async update() {
362
+ if (this.visible) {
363
+ await this.load();
364
+ }
365
+ }
366
+
367
+ async show() {
368
+ this.element.style.display = "block";
369
+ this.button.textContent = "Close";
370
+
371
+ await this.load();
372
+ }
373
+
374
+ hide() {
375
+ this.element.style.display = "none";
376
+ this.button.textContent = "See " + this.#text;
377
+ }
378
+
379
+ toggle() {
380
+ if (this.visible) {
381
+ this.hide();
382
+ return false;
383
+ } else {
384
+ this.show();
385
+ return true;
386
+ }
387
+ }
388
+ }
389
+
390
+ export class ComfyUI {
391
+ constructor(app) {
392
+ this.app = app;
393
+ this.dialog = new ComfyDialog();
394
+ this.settings = new ComfySettingsDialog();
395
+
396
+ this.batchCount = 1;
397
+ this.lastQueueSize = 0;
398
+ this.queue = new ComfyList("Queue");
399
+ this.history = new ComfyList("History");
400
+
401
+ api.addEventListener("status", () => {
402
+ this.queue.update();
403
+ this.history.update();
404
+ });
405
+
406
+ const fileInput = $el("input", {
407
+ type: "file",
408
+ accept: ".json,image/png",
409
+ style: { display: "none" },
410
+ parent: document.body,
411
+ onchange: () => {
412
+ app.handleFile(fileInput.files[0]);
413
+ },
414
+ });
415
+
416
+ this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [
417
+ $el("div", { style: { overflow: "hidden", position: "relative", width: "100%" } }, [
418
+ $el("span.drag-handle"),
419
+ $el("span", { $: (q) => (this.queueSize = q) }),
420
+ $el("button.comfy-settings-btn", { textContent: "⚙️", onclick: () => this.settings.show() }),
421
+ ]),
422
+ $el("button.comfy-queue-btn", {
423
+ textContent: "Queue Prompt",
424
+ onclick: () => app.queuePrompt(0, this.batchCount),
425
+ }),
426
+ $el("div", {}, [
427
+ $el("label", { innerHTML: "Extra options" }, [
428
+ $el("input", {
429
+ type: "checkbox",
430
+ onchange: (i) => {
431
+ document.getElementById("extraOptions").style.display = i.srcElement.checked ? "block" : "none";
432
+ this.batchCount = i.srcElement.checked ? document.getElementById("batchCountInputRange").value : 1;
433
+ document.getElementById("autoQueueCheckbox").checked = false;
434
+ },
435
+ }),
436
+ ]),
437
+ ]),
438
+ $el("div", { id: "extraOptions", style: { width: "100%", display: "none" } }, [
439
+ $el("label", { innerHTML: "Batch count" }, [
440
+ $el("input", {
441
+ id: "batchCountInputNumber",
442
+ type: "number",
443
+ value: this.batchCount,
444
+ min: "1",
445
+ style: { width: "35%", "margin-left": "0.4em" },
446
+ oninput: (i) => {
447
+ this.batchCount = i.target.value;
448
+ document.getElementById("batchCountInputRange").value = this.batchCount;
449
+ },
450
+ }),
451
+ $el("input", {
452
+ id: "batchCountInputRange",
453
+ type: "range",
454
+ min: "1",
455
+ max: "100",
456
+ value: this.batchCount,
457
+ oninput: (i) => {
458
+ this.batchCount = i.srcElement.value;
459
+ document.getElementById("batchCountInputNumber").value = i.srcElement.value;
460
+ },
461
+ }),
462
+ $el("input", {
463
+ id: "autoQueueCheckbox",
464
+ type: "checkbox",
465
+ checked: false,
466
+ title: "automatically queue prompt when the queue size hits 0",
467
+ }),
468
+ ]),
469
+ ]),
470
+ $el("div.comfy-menu-btns", [
471
+ $el("button", { textContent: "Queue Front", onclick: () => app.queuePrompt(-1, this.batchCount) }),
472
+ $el("button", {
473
+ $: (b) => (this.queue.button = b),
474
+ textContent: "View Queue",
475
+ onclick: () => {
476
+ this.history.hide();
477
+ this.queue.toggle();
478
+ },
479
+ }),
480
+ $el("button", {
481
+ $: (b) => (this.history.button = b),
482
+ textContent: "View History",
483
+ onclick: () => {
484
+ this.queue.hide();
485
+ this.history.toggle();
486
+ },
487
+ }),
488
+ ]),
489
+ this.queue.element,
490
+ this.history.element,
491
+ $el("button", {
492
+ textContent: "Save",
493
+ onclick: () => {
494
+ const json = JSON.stringify(app.graph.serialize(), null, 2); // convert the data to a JSON string
495
+ const blob = new Blob([json], { type: "application/json" });
496
+ const url = URL.createObjectURL(blob);
497
+ const a = $el("a", {
498
+ href: url,
499
+ download: "workflow.json",
500
+ style: { display: "none" },
501
+ parent: document.body,
502
+ });
503
+ a.click();
504
+ setTimeout(function () {
505
+ a.remove();
506
+ window.URL.revokeObjectURL(url);
507
+ }, 0);
508
+ },
509
+ }),
510
+ $el("button", { textContent: "Load", onclick: () => fileInput.click() }),
511
+ $el("button", { textContent: "Refresh", onclick: () => app.refreshComboInNodes() }),
512
+ $el("button", { textContent: "Clear", onclick: () => {
513
+ app.clean();
514
+ app.graph.clear();
515
+ }}),
516
+ $el("button", { textContent: "Load Default", onclick: () => app.loadGraphData() }),
517
+ ]);
518
+
519
+ dragElement(this.menuContainer, this.settings);
520
+
521
+ this.setStatus({ exec_info: { queue_remaining: "X" } });
522
+ }
523
+
524
+ setStatus(status) {
525
+ this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR");
526
+ if (status) {
527
+ if (
528
+ this.lastQueueSize != 0 &&
529
+ status.exec_info.queue_remaining == 0 &&
530
+ document.getElementById("autoQueueCheckbox").checked
531
+ ) {
532
+ app.queuePrompt(0, this.batchCount);
533
+ }
534
+ this.lastQueueSize = status.exec_info.queue_remaining;
535
+ }
536
+ }
537
+ }
web/scripts/widgets.js ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function getNumberDefaults(inputData, defaultStep) {
2
+ let defaultVal = inputData[1]["default"];
3
+ let { min, max, step } = inputData[1];
4
+
5
+ if (defaultVal == undefined) defaultVal = 0;
6
+ if (min == undefined) min = 0;
7
+ if (max == undefined) max = 2048;
8
+ if (step == undefined) step = defaultStep;
9
+
10
+ return { val: defaultVal, config: { min, max, step: 10.0 * step } };
11
+ }
12
+
13
+ export function addRandomizeWidget(node, targetWidget, name, defaultValue = false) {
14
+ const randomize = node.addWidget("toggle", name, defaultValue, function (v) {}, {
15
+ on: "enabled",
16
+ off: "disabled",
17
+ serialize: false, // Don't include this in prompt.
18
+ });
19
+
20
+ randomize.afterQueued = () => {
21
+ if (randomize.value) {
22
+ const min = targetWidget.options?.min;
23
+ let max = targetWidget.options?.max;
24
+ if (min != null || max != null) {
25
+ if (max) {
26
+ // limit max to something that javascript can handle
27
+ max = Math.min(1125899906842624, max);
28
+ }
29
+ targetWidget.value = Math.floor(Math.random() * ((max ?? 9999999999) - (min ?? 0) + 1) + (min ?? 0));
30
+ } else {
31
+ targetWidget.value = Math.floor(Math.random() * 1125899906842624);
32
+ }
33
+ }
34
+ };
35
+ return randomize;
36
+ }
37
+
38
+ function seedWidget(node, inputName, inputData) {
39
+ const seed = ComfyWidgets.INT(node, inputName, inputData);
40
+ const randomize = addRandomizeWidget(node, seed.widget, "Random seed after every gen", true);
41
+
42
+ seed.widget.linkedWidgets = [randomize];
43
+ return { widget: seed, randomize };
44
+ }
45
+
46
+ const MultilineSymbol = Symbol();
47
+ const MultilineResizeSymbol = Symbol();
48
+
49
+ function addMultilineWidget(node, name, opts, app) {
50
+ const MIN_SIZE = 50;
51
+
52
+ function computeSize(size) {
53
+ if (node.widgets[0].last_y == null) return;
54
+
55
+ let y = node.widgets[0].last_y;
56
+ let freeSpace = size[1] - y;
57
+
58
+ // Compute the height of all non customtext widgets
59
+ let widgetHeight = 0;
60
+ const multi = [];
61
+ for (let i = 0; i < node.widgets.length; i++) {
62
+ const w = node.widgets[i];
63
+ if (w.type === "customtext") {
64
+ multi.push(w);
65
+ } else {
66
+ if (w.computeSize) {
67
+ widgetHeight += w.computeSize()[1] + 4;
68
+ } else {
69
+ widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
70
+ }
71
+ }
72
+ }
73
+
74
+ // See how large each text input can be
75
+ freeSpace -= widgetHeight;
76
+ freeSpace /= multi.length;
77
+
78
+ if (freeSpace < MIN_SIZE) {
79
+ // There isnt enough space for all the widgets, increase the size of the node
80
+ freeSpace = MIN_SIZE;
81
+ node.size[1] = y + widgetHeight + freeSpace * multi.length;
82
+ node.graph.setDirtyCanvas(true);
83
+ }
84
+
85
+ // Position each of the widgets
86
+ for (const w of node.widgets) {
87
+ w.y = y;
88
+ if (w.type === "customtext") {
89
+ y += freeSpace;
90
+ } else if (w.computeSize) {
91
+ y += w.computeSize()[1] + 4;
92
+ } else {
93
+ y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
94
+ }
95
+ }
96
+
97
+ node.inputHeight = freeSpace;
98
+ }
99
+
100
+ const widget = {
101
+ type: "customtext",
102
+ name,
103
+ get value() {
104
+ return this.inputEl.value;
105
+ },
106
+ set value(x) {
107
+ this.inputEl.value = x;
108
+ },
109
+ draw: function (ctx, _, widgetWidth, y, widgetHeight) {
110
+ if (!this.parent.inputHeight) {
111
+ // If we are initially offscreen when created we wont have received a resize event
112
+ // Calculate it here instead
113
+ computeSize(node.size);
114
+ }
115
+ const visible = app.canvas.ds.scale > 0.5 && this.type === "customtext";
116
+ const t = ctx.getTransform();
117
+ const margin = 10;
118
+ Object.assign(this.inputEl.style, {
119
+ left: `${t.a * margin + t.e}px`,
120
+ top: `${t.d * (y + widgetHeight - margin - 3) + t.f}px`,
121
+ width: `${(widgetWidth - margin * 2 - 3) * t.a}px`,
122
+ height: `${(this.parent.inputHeight - margin * 2 - 4) * t.d}px`,
123
+ position: "absolute",
124
+ zIndex: 1,
125
+ fontSize: `${t.d * 10.0}px`,
126
+ });
127
+ this.inputEl.hidden = !visible;
128
+ },
129
+ };
130
+ widget.inputEl = document.createElement("textarea");
131
+ widget.inputEl.className = "comfy-multiline-input";
132
+ widget.inputEl.value = opts.defaultVal;
133
+ widget.inputEl.placeholder = opts.placeholder || "";
134
+ document.addEventListener("mousedown", function (event) {
135
+ if (!widget.inputEl.contains(event.target)) {
136
+ widget.inputEl.blur();
137
+ }
138
+ });
139
+ widget.parent = node;
140
+ document.body.appendChild(widget.inputEl);
141
+
142
+ node.addCustomWidget(widget);
143
+
144
+ app.canvas.onDrawBackground = function () {
145
+ // Draw node isnt fired once the node is off the screen
146
+ // if it goes off screen quickly, the input may not be removed
147
+ // this shifts it off screen so it can be moved back if the node is visible.
148
+ for (let n in app.graph._nodes) {
149
+ n = graph._nodes[n];
150
+ for (let w in n.widgets) {
151
+ let wid = n.widgets[w];
152
+ if (Object.hasOwn(wid, "inputEl")) {
153
+ wid.inputEl.style.left = -8000 + "px";
154
+ wid.inputEl.style.position = "absolute";
155
+ }
156
+ }
157
+ }
158
+ };
159
+
160
+ node.onRemoved = function () {
161
+ // When removing this node we need to remove the input from the DOM
162
+ for (let y in this.widgets) {
163
+ if (this.widgets[y].inputEl) {
164
+ this.widgets[y].inputEl.remove();
165
+ }
166
+ }
167
+ };
168
+
169
+ widget.onRemove = () => {
170
+ widget.inputEl?.remove();
171
+
172
+ // Restore original size handler if we are the last
173
+ if (!--node[MultilineSymbol]) {
174
+ node.onResize = node[MultilineResizeSymbol];
175
+ delete node[MultilineSymbol];
176
+ delete node[MultilineResizeSymbol];
177
+ }
178
+ };
179
+
180
+ if (node[MultilineSymbol]) {
181
+ node[MultilineSymbol]++;
182
+ } else {
183
+ node[MultilineSymbol] = 1;
184
+ const onResize = (node[MultilineResizeSymbol] = node.onResize);
185
+
186
+ node.onResize = function (size) {
187
+ computeSize(size);
188
+
189
+ // Call original resizer handler
190
+ if (onResize) {
191
+ onResize.apply(this, arguments);
192
+ }
193
+ };
194
+ }
195
+
196
+ return { minWidth: 400, minHeight: 200, widget };
197
+ }
198
+
199
+ export const ComfyWidgets = {
200
+ "INT:seed": seedWidget,
201
+ "INT:noise_seed": seedWidget,
202
+ FLOAT(node, inputName, inputData) {
203
+ const { val, config } = getNumberDefaults(inputData, 0.5);
204
+ return { widget: node.addWidget("number", inputName, val, () => {}, config) };
205
+ },
206
+ INT(node, inputName, inputData) {
207
+ const { val, config } = getNumberDefaults(inputData, 1);
208
+ Object.assign(config, { precision: 0 });
209
+ return {
210
+ widget: node.addWidget(
211
+ "number",
212
+ inputName,
213
+ val,
214
+ function (v) {
215
+ const s = this.options.step / 10;
216
+ this.value = Math.round(v / s) * s;
217
+ },
218
+ config
219
+ ),
220
+ };
221
+ },
222
+ STRING(node, inputName, inputData, app) {
223
+ const defaultVal = inputData[1].default || "";
224
+ const multiline = !!inputData[1].multiline;
225
+
226
+ if (multiline) {
227
+ return addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app);
228
+ } else {
229
+ return { widget: node.addWidget("text", inputName, defaultVal, () => {}, {}) };
230
+ }
231
+ },
232
+ COMBO(node, inputName, inputData) {
233
+ const type = inputData[0];
234
+ let defaultValue = type[0];
235
+ if (inputData[1] && inputData[1].default) {
236
+ defaultValue = inputData[1].default;
237
+ }
238
+ return { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) };
239
+ },
240
+ IMAGEUPLOAD(node, inputName, inputData, app) {
241
+ const imageWidget = node.widgets.find((w) => w.name === "image");
242
+ let uploadWidget;
243
+
244
+ function showImage(name) {
245
+ // Position the image somewhere sensible
246
+ if (!node.imageOffset) {
247
+ node.imageOffset = uploadWidget.last_y ? uploadWidget.last_y + 25 : 75;
248
+ }
249
+
250
+ const img = new Image();
251
+ img.onload = () => {
252
+ node.imgs = [img];
253
+ app.graph.setDirtyCanvas(true);
254
+ };
255
+ img.src = `/view?filename=${name}&type=input`;
256
+ }
257
+
258
+ // Add our own callback to the combo widget to render an image when it changes
259
+ const cb = node.callback;
260
+ imageWidget.callback = function () {
261
+ showImage(imageWidget.value);
262
+ if (cb) {
263
+ return cb.apply(this, arguments);
264
+ }
265
+ };
266
+
267
+ // On load if we have a value then render the image
268
+ // The value isnt set immediately so we need to wait a moment
269
+ // No change callbacks seem to be fired on initial setting of the value
270
+ requestAnimationFrame(() => {
271
+ if (imageWidget.value) {
272
+ showImage(imageWidget.value);
273
+ }
274
+ });
275
+
276
+ async function uploadFile(file, updateNode) {
277
+ try {
278
+ // Wrap file in formdata so it includes filename
279
+ const body = new FormData();
280
+ body.append("image", file);
281
+ const resp = await fetch("/upload/image", {
282
+ method: "POST",
283
+ body,
284
+ });
285
+
286
+ if (resp.status === 200) {
287
+ const data = await resp.json();
288
+ // Add the file as an option and update the widget value
289
+ if (!imageWidget.options.values.includes(data.name)) {
290
+ imageWidget.options.values.push(data.name);
291
+ }
292
+
293
+ if (updateNode) {
294
+ showImage(data.name);
295
+
296
+ imageWidget.value = data.name;
297
+ }
298
+ } else {
299
+ alert(resp.status + " - " + resp.statusText);
300
+ }
301
+ } catch (error) {
302
+ alert(error);
303
+ }
304
+ }
305
+
306
+ const fileInput = document.createElement("input");
307
+ Object.assign(fileInput, {
308
+ type: "file",
309
+ accept: "image/jpeg,image/png",
310
+ style: "display: none",
311
+ onchange: async () => {
312
+ if (fileInput.files.length) {
313
+ await uploadFile(fileInput.files[0], true);
314
+ }
315
+ },
316
+ });
317
+ document.body.append(fileInput);
318
+
319
+ // Create the button widget for selecting the files
320
+ uploadWidget = node.addWidget("button", "choose file to upload", "image", () => {
321
+ fileInput.click();
322
+ });
323
+ uploadWidget.serialize = false;
324
+
325
+ // Add handler to check if an image is being dragged over our node
326
+ node.onDragOver = function (e) {
327
+ if (e.dataTransfer && e.dataTransfer.items) {
328
+ const image = [...e.dataTransfer.items].find((f) => f.kind === "file" && f.type.startsWith("image/"));
329
+ return !!image;
330
+ }
331
+
332
+ return false;
333
+ };
334
+
335
+ // On drop upload files
336
+ node.onDragDrop = function (e) {
337
+ console.log("onDragDrop called");
338
+ let handled = false;
339
+ for (const file of e.dataTransfer.files) {
340
+ if (file.type.startsWith("image/")) {
341
+ uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one
342
+ handled = true;
343
+ }
344
+ }
345
+
346
+ return handled;
347
+ };
348
+
349
+ return { widget: uploadWidget };
350
+ },
351
+ };
web/style.css ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --fg-color: #000;
3
+ --bg-color: #fff;
4
+ }
5
+
6
+ @media (prefers-color-scheme: dark) {
7
+ :root {
8
+ --fg-color: #fff;
9
+ --bg-color: #202020;
10
+ }
11
+ }
12
+
13
+ body {
14
+ width: 100vw;
15
+ height: 100vh;
16
+ margin: 0;
17
+ overflow: hidden;
18
+ background-color: var(--bg-color);
19
+ color: var(--fg-color);
20
+ }
21
+
22
+ #graph-canvas {
23
+ width: 100%;
24
+ height: 100%;
25
+ }
26
+
27
+ .comfy-multiline-input {
28
+ background-color: var(--bg-color);
29
+ color: var(--fg-color);
30
+ overflow: hidden;
31
+ overflow-y: auto;
32
+ padding: 2px;
33
+ resize: none;
34
+ border: none;
35
+ }
36
+
37
+ .comfy-modal {
38
+ display: none; /* Hidden by default */
39
+ position: fixed; /* Stay in place */
40
+ z-index: 100; /* Sit on top */
41
+ padding: 30px 30px 10px 30px;
42
+ background-color: #ff0000; /* Modal background */
43
+ box-shadow: 0px 0px 20px #888888;
44
+ border-radius: 10px;
45
+ text-align: center;
46
+ top: 50%;
47
+ left: 50%;
48
+ max-width: 80vw;
49
+ max-height: 80vh;
50
+ transform: translate(-50%, -50%);
51
+ overflow: hidden;
52
+ min-width: 60%;
53
+ justify-content: center;
54
+ }
55
+
56
+ .comfy-modal-content {
57
+ display: flex;
58
+ flex-direction: column;
59
+ }
60
+
61
+ .comfy-modal p {
62
+ overflow: auto;
63
+ white-space: pre-line; /* This will respect line breaks */
64
+ margin-bottom: 20px; /* Add some margin between the text and the close button*/
65
+ }
66
+
67
+ .comfy-modal select,
68
+ .comfy-modal input[type=button],
69
+ .comfy-modal input[type=checkbox] {
70
+ margin: 3px 3px 3px 4px;
71
+ }
72
+
73
+ .comfy-modal button {
74
+ cursor: pointer;
75
+ color: #aaaaaa;
76
+ border: none;
77
+ background-color: transparent;
78
+ font-size: 24px;
79
+ font-weight: bold;
80
+ width: 100%;
81
+ }
82
+
83
+ .comfy-modal button:hover,
84
+ .comfy-modal button:focus {
85
+ color: #000;
86
+ text-decoration: none;
87
+ cursor: pointer;
88
+ }
89
+
90
+ .comfy-menu {
91
+ width: 200px;
92
+ font-size: 15px;
93
+ position: absolute;
94
+ top: 50%;
95
+ right: 0%;
96
+ background-color: white;
97
+ color: #000;
98
+ text-align: center;
99
+ z-index: 100;
100
+ width: 170px;
101
+ display: flex;
102
+ flex-direction: column;
103
+ align-items: center;
104
+ color: #999;
105
+ background-color: #353535;
106
+ font-family: sans-serif;
107
+ padding: 10px;
108
+ border-radius: 0 8px 8px 8px;
109
+ box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
110
+ }
111
+
112
+ .comfy-menu button {
113
+ font-size: 20px;
114
+ }
115
+
116
+ .comfy-menu-btns {
117
+ margin-bottom: 10px;
118
+ width: 100%;
119
+ }
120
+
121
+ .comfy-menu-btns button {
122
+ font-size: 10px;
123
+ width: 50%;
124
+ color: #999 !important;
125
+ }
126
+
127
+ .comfy-menu > button {
128
+ width: 100%;
129
+ }
130
+
131
+ .comfy-menu > button,
132
+ .comfy-menu-btns button,
133
+ .comfy-menu .comfy-list button {
134
+ color: #ddd;
135
+ background-color: #222;
136
+ border-radius: 8px;
137
+ border-color: #4e4e4e;
138
+ border-style: solid;
139
+ margin-top: 2px;
140
+ }
141
+
142
+ .comfy-menu span.drag-handle {
143
+ width: 10px;
144
+ height: 20px;
145
+ display: inline-block;
146
+ overflow: hidden;
147
+ line-height: 5px;
148
+ padding: 3px 4px;
149
+ cursor: move;
150
+ vertical-align: middle;
151
+ margin-top: -.4em;
152
+ margin-left: -.2em;
153
+ font-size: 12px;
154
+ font-family: sans-serif;
155
+ letter-spacing: 2px;
156
+ color: #cccccc;
157
+ text-shadow: 1px 0 1px black;
158
+ position: absolute;
159
+ top: 0;
160
+ left: 0;
161
+ }
162
+
163
+ .comfy-menu span.drag-handle::after {
164
+ content: '.. .. ..';
165
+ }
166
+
167
+ .comfy-queue-btn {
168
+ width: 100%;
169
+ }
170
+
171
+ .comfy-list {
172
+ color: #999;
173
+ background-color: #333;
174
+ margin-bottom: 10px;
175
+ border-color: #4e4e4e;
176
+ border-style: solid;
177
+ }
178
+
179
+ .comfy-list-items {
180
+ overflow-y: scroll;
181
+ max-height: 100px;
182
+ min-height: 25px;
183
+ background-color: #222;
184
+ padding: 5px;
185
+ }
186
+
187
+ .comfy-list h4 {
188
+ min-width: 160px;
189
+ margin: 0;
190
+ padding: 3px;
191
+ font-weight: normal;
192
+ }
193
+
194
+ .comfy-list-items button {
195
+ font-size: 10px;
196
+ }
197
+
198
+ .comfy-list-actions {
199
+ margin: 5px;
200
+ display: flex;
201
+ gap: 5px;
202
+ justify-content: center;
203
+ }
204
+
205
+ .comfy-list-actions button {
206
+ font-size: 12px;
207
+ }
208
+
209
+ button.comfy-settings-btn {
210
+ background-color: rgba(0, 0, 0, 0);
211
+ font-size: 12px;
212
+ padding: 0;
213
+ position: absolute;
214
+ right: 0;
215
+ border: none;
216
+ }
217
+
218
+ button.comfy-queue-btn {
219
+ margin: 6px 0 !important;
220
+ }
221
+
222
+ .comfy-modal.comfy-settings {
223
+ background-color: var(--bg-color);
224
+ color: var(--fg-color);
225
+ z-index: 99;
226
+ }
227
+
228
+ @media only screen and (max-height: 850px) {
229
+ .comfy-menu {
230
+ top: 0 !important;
231
+ bottom: 0 !important;
232
+ left: auto !important;
233
+ right: 0 !important;
234
+ border-radius: 0px;
235
+ }
236
+ .comfy-menu span.drag-handle {
237
+ visibility:hidden
238
+ }
239
+ }