Upload 21 files
Browse files- web/extensions/core/colorPalette.js +351 -0
- web/extensions/core/dynamicPrompts.js +40 -0
- web/extensions/core/invertMenuScrolling.js +36 -0
- web/extensions/core/rerouteNode.js +201 -0
- web/extensions/core/saveImageExtraOutput.js +100 -0
- web/extensions/core/slotDefaults.js +21 -0
- web/extensions/core/snapToGrid.js +89 -0
- web/extensions/core/uploadImage.js +12 -0
- web/extensions/core/widgetInputs.js +362 -0
- web/extensions/logging.js.example +55 -0
- web/index.html +17 -0
- web/jsconfig.json +9 -0
- web/lib/litegraph.core.js +0 -0
- web/lib/litegraph.css +680 -0
- web/scripts/api.js +263 -0
- web/scripts/app.js +1046 -0
- web/scripts/defaultGraph.js +119 -0
- web/scripts/pnginfo.js +306 -0
- web/scripts/ui.js +537 -0
- web/scripts/widgets.js +351 -0
- web/style.css +239 -0
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 |
+
}
|