From d934119333a6e3e77de24084ad93a9ecad1f8fc2 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 11:33:45 -0500 Subject: [PATCH 01/11] Image multi upload widget --- nodes.py | 69 ++++- web/extensions/core/uploadImage.js | 8 +- web/scripts/app.js | 13 +- web/scripts/widgets.js | 424 +++++++++++++++++++++-------- 4 files changed, 388 insertions(+), 126 deletions(-) diff --git a/nodes.py b/nodes.py index b057504edae3..6f05e4b77844 100644 --- a/nodes.py +++ b/nodes.py @@ -1085,7 +1085,7 @@ def INPUT_TYPES(s): input_dir = folder_paths.get_input_directory() files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] return {"required": - {"image": (sorted(files), )}, + {"image": (sorted(files), { "forceInput": True })}, } CATEGORY = "image" @@ -1121,6 +1121,72 @@ def VALIDATE_INPUTS(s, image): return True +class LoadImageBatch: + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + return {"required": + {"images": (sorted(files), )}, + } + + CATEGORY = "image" + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "load_images" + + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True, True, ) + + def load_images(self, images): + output_images = [] + output_masks = [] + + for i in range(len(images)): + image_path = folder_paths.get_annotated_filepath(images[i]) + + i = Image.open(image_path) + i = ImageOps.exif_transpose(i) + image = i.convert("RGB") + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") + + output_images.append(image) + output_masks.append(mask) + + return (output_images, output_masks, ) + + @classmethod + def IS_CHANGED(s, images): + hashes = [] + + for image in images: + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + hashes.append(m.digest().hex()) + + return hashes + + @classmethod + def VALIDATE_INPUTS(s, images): + invalid = [] + + for image in images: + if not folder_paths.exists_annotated_filepath(image): + invalid.append(image) + + if len(invalid) > 0: + return "Invalid image file(s): {}".format(", ".join(invalid)) + + return True + class LoadImageMask: _color_channels = ["alpha", "red", "green", "blue"] @classmethod @@ -1289,6 +1355,7 @@ def expand_image(self, image, left, top, right, bottom, feathering): "PreviewImage": PreviewImage, "LoadImage": LoadImage, "LoadImageMask": LoadImageMask, + "LoadImageBatch": LoadImageBatch, "ImageScale": ImageScale, "ImageInvert": ImageInvert, "ImagePadForOutpaint": ImagePadForOutpaint, diff --git a/web/extensions/core/uploadImage.js b/web/extensions/core/uploadImage.js index 45fabb78ed74..e2ecfae861c9 100644 --- a/web/extensions/core/uploadImage.js +++ b/web/extensions/core/uploadImage.js @@ -5,8 +5,14 @@ import { app } from "/scripts/app.js"; app.registerExtension({ name: "Comfy.UploadImage", async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "LoadImage" || nodeData.name === "LoadImageMask") { + switch (nodeData.name) { + case "LoadImage": + case "LoadImageMask": nodeData.input.required.upload = ["IMAGEUPLOAD"]; + break; + case "LoadImageBatch": + nodeData.input.required.upload = ["MULTIIMAGEUPLOAD"]; + break; } }, }); diff --git a/web/scripts/app.js b/web/scripts/app.js index 385a54579552..fd1186ab916f 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -1110,22 +1110,23 @@ export class ComfyApp { for (const inputName in inputs) { const inputData = inputs[inputName]; const type = inputData[0]; + const inputShape = nodeData["input_is_list"] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE; if(inputData[1]?.forceInput) { - this.addInput(inputName, type); + this.addInput(inputName, type, { shape: inputShape }); } else { if (Array.isArray(type)) { // Enums - Object.assign(config, widgets.COMBO(this, inputName, inputData, app) || {}); + Object.assign(config, widgets.COMBO(this, inputName, inputData, nodeData, app) || {}); } else if (`${type}:${inputName}` in widgets) { // Support custom widgets by Type:Name - Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {}); + Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, nodeData, app) || {}); } else if (type in widgets) { // Standard type widgets - Object.assign(config, widgets[type](this, inputName, inputData, app) || {}); + Object.assign(config, widgets[type](this, inputName, inputData, nodeData, app) || {}); } else { // Node connection inputs - this.addInput(inputName, type); + this.addInput(inputName, type, { shape: inputShape }); } } } @@ -1133,7 +1134,7 @@ export class ComfyApp { for (const o in nodeData["output"]) { const output = nodeData["output"][o]; const outputName = nodeData["output_name"][o] || output; - const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE ; + const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE; this.addOutput(outputName, output, { shape: outputShape }); } diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index dfa26aef430e..784e2740d8f8 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -246,55 +246,217 @@ function addMultilineWidget(node, name, opts, app) { return { minWidth: 400, minHeight: 200, widget }; } -export const ComfyWidgets = { - "INT:seed": seedWidget, - "INT:noise_seed": seedWidget, - FLOAT(node, inputName, inputData) { - const { val, config } = getNumberDefaults(inputData, 0.5); - return { widget: node.addWidget("number", inputName, val, () => {}, config) }; - }, - INT(node, inputName, inputData) { - const { val, config } = getNumberDefaults(inputData, 1); - Object.assign(config, { precision: 0 }); - return { - widget: node.addWidget( - "number", - inputName, - val, - function (v) { - const s = this.options.step / 10; - this.value = Math.round(v / s) * s; - }, - config - ), +const FLOAT = (node, inputName, inputData) => { + const { val, config } = getNumberDefaults(inputData, 0.5); + return { widget: node.addWidget("number", inputName, val, () => {}, config) }; +} + +const INT = (node, inputName, inputData) => { + const { val, config } = getNumberDefaults(inputData, 1); + Object.assign(config, { precision: 0 }); + return { + widget: node.addWidget( + "number", + inputName, + val, + function (v) { + const s = this.options.step / 10; + this.value = Math.round(v / s) * s; + }, + config + ), + }; +} + +const STRING = (node, inputName, inputData, nodeData, app) => { + const defaultVal = inputData[1].default || ""; + const multiline = !!inputData[1].multiline; + + if (multiline) { + return addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app); + } else { + return { widget: node.addWidget("text", inputName, defaultVal, () => {}, {}) }; + } +} + +const COMBO = (node, inputName, inputData, nodeData) => { + const type = inputData[0]; + let defaultValue = type[0]; + if (inputData[1] && inputData[1].default) { + defaultValue = inputData[1].default; + } + + if (nodeData["input_is_list"]) { + defaultValue = [defaultValue] + const widget = node.addWidget("text", inputName, defaultValue, () => {}, { values: type }) + widget.disabled = true; + return { widget }; + } + else { + return { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) }; + } +} + +const IMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { + const imageWidget = node.widgets.find((w) => w.name === "image"); + let uploadWidget; + + function showImage(name) { + const img = new Image(); + img.onload = () => { + node.imgs = [img]; + app.graph.setDirtyCanvas(true); }; - }, - STRING(node, inputName, inputData, app) { - const defaultVal = inputData[1].default || ""; - const multiline = !!inputData[1].multiline; - - if (multiline) { - return addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app); - } else { - return { widget: node.addWidget("text", inputName, defaultVal, () => {}, {}) }; + let folder_separator = name.lastIndexOf("/"); + let subfolder = ""; + if (folder_separator > -1) { + subfolder = name.substring(0, folder_separator); + name = name.substring(folder_separator + 1); + } + img.src = `/view?filename=${name}&type=input&subfolder=${subfolder}`; + node.setSizeForImage?.(); + } + + var default_value = imageWidget.value; + Object.defineProperty(imageWidget, "value", { + set : function(value) { + this._real_value = value; + }, + + get : function() { + let value = ""; + if (this._real_value) { + value = this._real_value; + } else { + return default_value; + } + + if (value.filename) { + let real_value = value; + value = ""; + if (real_value.subfolder) { + value = real_value.subfolder + "/"; + } + + value += real_value.filename; + + if(real_value.type && real_value.type !== "input") + value += ` [${real_value.type}]`; + } + return value; } - }, - COMBO(node, inputName, inputData) { - const type = inputData[0]; - let defaultValue = type[0]; - if (inputData[1] && inputData[1].default) { - defaultValue = inputData[1].default; + }); + + // Add our own callback to the combo widget to render an image when it changes + const cb = node.callback; + imageWidget.callback = function () { + showImage(imageWidget.value); + if (cb) { + return cb.apply(this, arguments); } - return { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) }; - }, - IMAGEUPLOAD(node, inputName, inputData, app) { - const imageWidget = node.widgets.find((w) => w.name === "image"); - let uploadWidget; + }; + + // On load if we have a value then render the image + // The value isnt set immediately so we need to wait a moment + // No change callbacks seem to be fired on initial setting of the value + requestAnimationFrame(() => { + if (imageWidget.value) { + showImage(imageWidget.value); + } + }); + + async function uploadFile(file, updateNode) { + try { + // Wrap file in formdata so it includes filename + const body = new FormData(); + body.append("image", file); + const resp = await fetch("/upload/image", { + method: "POST", + body, + }); - function showImage(name) { + if (resp.status === 200) { + const data = await resp.json(); + // Add the file as an option and update the widget value + if (!imageWidget.options.values.includes(data.name)) { + imageWidget.options.values.push(data.name); + } + + if (updateNode) { + showImage(data.name); + + imageWidget.value = data.name; + } + } else { + alert(resp.status + " - " + resp.statusText); + } + } catch (error) { + alert(error); + } + } + + const fileInput = document.createElement("input"); + Object.assign(fileInput, { + type: "file", + accept: "image/jpeg,image/png,image/webp", + style: "display: none", + onchange: async () => { + if (fileInput.files.length) { + await uploadFile(fileInput.files[0], true); + } + }, + }); + document.body.append(fileInput); + + // Create the button widget for selecting the files + uploadWidget = node.addWidget("button", "choose file to upload", "image", () => { + fileInput.value = null; + fileInput.click(); + }); + uploadWidget.serialize = false; + + // Add handler to check if an image is being dragged over our node + node.onDragOver = function (e) { + if (e.dataTransfer && e.dataTransfer.items) { + const image = [...e.dataTransfer.items].find((f) => f.kind === "file" && f.type.startsWith("image/")); + return !!image; + } + + return false; + }; + + // On drop upload files + node.onDragDrop = function (e) { + console.log("onDragDrop called"); + let handled = false; + for (const file of e.dataTransfer.files) { + if (file.type.startsWith("image/")) { + uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one + handled = true; + } + } + + return handled; + }; + + return { widget: uploadWidget }; +} + +const MULTIIMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { + const imagesWidget = node.widgets.find((w) => w.name === "images"); + let uploadWidget; + let clearWidget; + + function showImages(names) { + node.imgs = [] + + for (const name of names) { const img = new Image(); img.onload = () => { - node.imgs = [img]; + // TODO await this? + node.imgs.push(img) + node.imageIndex = null; + node.setSizeForImage?.(); app.graph.setDirtyCanvas(true); }; let folder_separator = name.lastIndexOf("/"); @@ -306,21 +468,20 @@ export const ComfyWidgets = { img.src = `/view?filename=${name}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}`; node.setSizeForImage?.(); } + } - var default_value = imageWidget.value; - Object.defineProperty(imageWidget, "value", { - set : function(value) { - this._real_value = value; - }, + var default_value = imagesWidget.value; + Object.defineProperty(imagesWidget, "value", { + set : function(value) { + this._real_value = value; + }, - get : function() { - let value = ""; - if (this._real_value) { - value = this._real_value; - } else { - return default_value; - } + get : function() { + this._real_value ||= [] + const result = [] + + for (const value of this._real_value) { if (value.filename) { let real_value = value; value = ""; @@ -333,29 +494,35 @@ export const ComfyWidgets = { if(real_value.type && real_value.type !== "input") value += ` [${real_value.type}]`; } - return value; - } - }); - // Add our own callback to the combo widget to render an image when it changes - const cb = node.callback; - imageWidget.callback = function () { - showImage(imageWidget.value); - if (cb) { - return cb.apply(this, arguments); + result.push(value) } - }; - // On load if we have a value then render the image - // The value isnt set immediately so we need to wait a moment - // No change callbacks seem to be fired on initial setting of the value - requestAnimationFrame(() => { - if (imageWidget.value) { - showImage(imageWidget.value); - } - }); + this._real_value = result + return this._real_value; + } + }); + + // Add our own callback to the combo widget to render an image when it changes + const cb = node.callback; + imagesWidget.callback = function () { + showImages(imagesWidget.value); + if (cb) { + return cb.apply(this, arguments); + } + }; - async function uploadFile(file, updateNode) { + // On load if we have a value then render the image + // The value isnt set immediately so we need to wait a moment + // No change callbacks seem to be fired on initial setting of the value + requestAnimationFrame(() => { + if (Array.isArray(imagesWidget.value) && imagesWidget.value.length > 0) { + showImages(imagesWidget.value); + } + }); + + async function uploadFiles(files, updateNode) { + for (const file of files) { try { // Wrap file in formdata so it includes filename const body = new FormData(); @@ -368,14 +535,12 @@ export const ComfyWidgets = { if (resp.status === 200) { const data = await resp.json(); // Add the file as an option and update the widget value - if (!imageWidget.options.values.includes(data.name)) { - imageWidget.options.values.push(data.name); + if (!imagesWidget.options.values.includes(data.name)) { + imagesWidget.options.values.push(data.name); } if (updateNode) { - showImage(data.name); - - imageWidget.value = data.name; + imagesWidget.value.push(data.name) } } else { alert(resp.status + " - " + resp.statusText); @@ -385,49 +550,72 @@ export const ComfyWidgets = { } } - const fileInput = document.createElement("input"); - Object.assign(fileInput, { - type: "file", - accept: "image/jpeg,image/png,image/webp", - style: "display: none", - onchange: async () => { - if (fileInput.files.length) { - await uploadFile(fileInput.files[0], true); - } - }, - }); - document.body.append(fileInput); - - // Create the button widget for selecting the files - uploadWidget = node.addWidget("button", "choose file to upload", "image", () => { - fileInput.click(); - }); - uploadWidget.serialize = false; - - // Add handler to check if an image is being dragged over our node - node.onDragOver = function (e) { - if (e.dataTransfer && e.dataTransfer.items) { - const image = [...e.dataTransfer.items].find((f) => f.kind === "file" && f.type.startsWith("image/")); - return !!image; + if (updateNode) { + showImages(imagesWidget.value); + } + } + + const fileInput = document.createElement("input"); + Object.assign(fileInput, { + type: "file", + multiple: "multiple", + accept: "image/jpeg,image/png,image/webp", + style: "display: none", + onchange: async () => { + if (fileInput.files.length) { + await uploadFiles(fileInput.files, true); } + }, + }); + document.body.append(fileInput); - return false; - }; + // Create the button widget for selecting the files + uploadWidget = node.addWidget("button", "choose files to upload", "images", () => { + fileInput.value = null; + fileInput.click(); + }); + uploadWidget.serialize = false; - // On drop upload files - node.onDragDrop = function (e) { - console.log("onDragDrop called"); - let handled = false; - for (const file of e.dataTransfer.files) { - if (file.type.startsWith("image/")) { - uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one - handled = true; - } + clearWidget = node.addWidget("button", "clear all uploads", "images", () => { + imagesWidget.value = [] + showImages(imagesWidget.value); + }); + clearWidget.serialize = false; + + // Add handler to check if an image is being dragged over our node + node.onDragOver = function (e) { + if (e.dataTransfer && e.dataTransfer.items) { + const image = [...e.dataTransfer.items].find((f) => f.kind === "file" && f.type.startsWith("image/")); + return !!image; + } + + return false; + }; + + // On drop upload files + node.onDragDrop = function (e) { + console.log("onDragDrop called"); + let handled = false; + for (const file of e.dataTransfer.files) { + if (file.type.startsWith("image/")) { + uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one + handled = true; } + } - return handled; - }; + return handled; + }; + + return { widget: uploadWidget }; +} - return { widget: uploadWidget }; - }, +export const ComfyWidgets = { + "INT:seed": seedWidget, + "INT:noise_seed": seedWidget, + FLOAT, + INT, + STRING, + COMBO, + IMAGEUPLOAD, + MULTIIMAGEUPLOAD, }; From e4be8e06668ac1dcce24e4aa08a17b72c57e9427 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 14:56:40 -0500 Subject: [PATCH 02/11] Allow specifying which inputs should be used as lists --- comfy_extras/nodes_rebatch.py | 9 +- execution.py | 435 +++++++++++++++++------------ nodes.py | 6 +- web/extensions/core/uploadImage.js | 3 - web/scripts/app.js | 21 +- web/scripts/widgets.js | 37 ++- 6 files changed, 291 insertions(+), 220 deletions(-) diff --git a/comfy_extras/nodes_rebatch.py b/comfy_extras/nodes_rebatch.py index 0a9daf272761..46c9c3105bc6 100644 --- a/comfy_extras/nodes_rebatch.py +++ b/comfy_extras/nodes_rebatch.py @@ -3,11 +3,10 @@ class LatentRebatch: @classmethod def INPUT_TYPES(s): - return {"required": { "latents": ("LATENT",), + return {"required": { "latents": ("LATENT", { "is_list": True }), "batch_size": ("INT", {"default": 1, "min": 1, "max": 64}), }} - RETURN_TYPES = ("LATENT",) - INPUT_IS_LIST = True + RETURN_TYPES = ("LATENT", ) OUTPUT_IS_LIST = (True, ) FUNCTION = "rebatch" @@ -54,8 +53,6 @@ def cat_batch(batch1, batch2): return result def rebatch(self, latents, batch_size): - batch_size = batch_size[0] - output_list = [] current_batch = (None, None, None) processed = 0 @@ -105,4 +102,4 @@ def rebatch(self, latents, batch_size): NODE_DISPLAY_NAME_MAPPINGS = { "RebatchLatents": "Rebatch Latents", -} \ No newline at end of file +} diff --git a/execution.py b/execution.py index 218a84c36df8..989c1a5b55fe 100644 --- a/execution.py +++ b/execution.py @@ -13,21 +13,44 @@ import comfy.model_management +def slice_lists_into_dict(d, i): + """ + get a slice of inputs, repeat last input when list isn't long enough + d={ "seed": [ 1, 2, 3 ], "steps": [ 4, 8 ] }, i=2 -> { "seed": 3, "steps": 8 } + """ + d_new = {} + for k, v in d.items(): + d_new[k] = v[i if len(v) > i else -1] + return d_new + def get_input_data(inputs, class_def, unique_id, outputs={}, prompt={}, extra_data={}): valid_inputs = class_def.INPUT_TYPES() input_data_all = {} + required = valid_inputs.get("required", {}) + optional = valid_inputs.get("optional", {}) for x in inputs: input_data = inputs[x] - if isinstance(input_data, list): - input_unique_id = input_data[0] - output_index = input_data[1] + input_type = input_data["type"] + + input_def = required.get(x) + if input_def is None: + input_def = optional.get(x) + + use_value_as_list = input_def is not None and len(input_def) > 1 and input_def[1].get("is_list", False) + + if input_type == "link": + input_unique_id = input_data["origin_id"] + output_index = input_data["origin_slot"] if input_unique_id not in outputs: return None obj = outputs[input_unique_id][output_index] + if use_value_as_list: + obj = [obj] input_data_all[x] = obj else: - if ("required" in valid_inputs and x in valid_inputs["required"]) or ("optional" in valid_inputs and x in valid_inputs["optional"]): - input_data_all[x] = [input_data] + value = input_data["value"] + if input_def is not None: + input_data_all[x] = [value] if "hidden" in valid_inputs: h = valid_inputs["hidden"] @@ -39,37 +62,23 @@ def get_input_data(inputs, class_def, unique_id, outputs={}, prompt={}, extra_da input_data_all[x] = [extra_data['extra_pnginfo']] if h[x] == "UNIQUE_ID": input_data_all[x] = [unique_id] + return input_data_all def map_node_over_list(obj, input_data_all, func, allow_interrupt=False): - # check if node wants the lists - intput_is_list = False - if hasattr(obj, "INPUT_IS_LIST"): - intput_is_list = obj.INPUT_IS_LIST - - max_len_input = max([len(x) for x in input_data_all.values()]) - - # get a slice of inputs, repeat last input when list isn't long enough - def slice_dict(d, i): - d_new = dict() - for k,v in d.items(): - d_new[k] = v[i if len(v) > i else -1] - return d_new - results = [] - if intput_is_list: + max_len_input = max([len(x) for x in input_data_all.values()]) + + for i in range(max_len_input): if allow_interrupt: nodes.before_node_execution() - results.append(getattr(obj, func)(**input_data_all)) - else: - for i in range(max_len_input): - if allow_interrupt: - nodes.before_node_execution() - results.append(getattr(obj, func)(**slice_dict(input_data_all, i))) + + args = slice_lists_into_dict(input_data_all, i) + results.append(getattr(obj, func)(**args)) + return results def get_output_data(obj, input_data_all): - results = [] uis = [] return_values = map_node_over_list(obj, input_data_all, obj.FUNCTION, allow_interrupt=True) @@ -120,10 +129,10 @@ def recursive_execute(server, prompt, outputs, current_item, extra_data, execute for x in inputs: input_data = inputs[x] + input_type = input_data["type"] - if isinstance(input_data, list): - input_unique_id = input_data[0] - output_index = input_data[1] + if input_type == "link": + input_unique_id = input_data["origin_id"] if input_unique_id not in outputs: result = recursive_execute(server, prompt, outputs, input_unique_id, extra_data, executed, prompt_id, outputs_ui) if result[0] is not True: @@ -192,9 +201,9 @@ def recursive_will_execute(prompt, outputs, current_item): for x in inputs: input_data = inputs[x] - if isinstance(input_data, list): - input_unique_id = input_data[0] - output_index = input_data[1] + input_type = input_data["type"] + if input_type == "link": + input_unique_id = input_data["origin_id"] if input_unique_id not in outputs: will_execute += recursive_will_execute(prompt, outputs, input_unique_id) @@ -235,10 +244,10 @@ def recursive_output_delete_if_changed(prompt, old_prompt, outputs, current_item elif inputs == old_prompt[unique_id]['inputs']: for x in inputs: input_data = inputs[x] + input_type = input_data["type"] - if isinstance(input_data, list): - input_unique_id = input_data[0] - output_index = input_data[1] + if input_type == "link": + input_unique_id = input_data["origin_id"] if input_unique_id in outputs: to_delete = recursive_output_delete_if_changed(prompt, old_prompt, outputs, input_unique_id) else: @@ -366,6 +375,150 @@ def execute(self, prompt, prompt_id, extra_data={}, execute_outputs=[]): comfy.model_management.soft_empty_cache() +def validate_link(prompt, x, val, info, validated): + type_input = info[0] + + o_id = val.get("origin_id", None) + o_slot = val.get("origin_slot", None) + + if o_id is None or o_slot is None: + error = { + "type": "bad_linked_input", + "message": "Bad linked input, must be a dictionary like { type: 'link', origin_id: 1, origin_slot: 1 }", + "details": f"{x}", + "extra_info": { + "input_name": x, + "input_config": info, + "received_value": val + } + } + return (False, error) + + o_class_type = prompt[o_id]['class_type'] + r = nodes.NODE_CLASS_MAPPINGS[o_class_type].RETURN_TYPES + if r[o_slot] != type_input: + received_type = r[val[1]] + details = f"{x}, {received_type} != {type_input}" + error = { + "type": "return_type_mismatch", + "message": "Return type mismatch between linked nodes", + "details": details, + "extra_info": { + "input_name": x, + "input_config": info, + "received_type": received_type, + "linked_node": val + } + } + return (False, error) + try: + r = validate_inputs(prompt, o_id, validated) + if r[0] is False: + # `r` will be set in `validated[o_id]` already + return (False, None) + except Exception as ex: + typ, _, tb = sys.exc_info() + exception_type = full_type_name(typ) + reasons = [{ + "type": "exception_during_inner_validation", + "message": "Exception when validating inner node", + "details": str(ex), + "extra_info": { + "input_name": x, + "input_config": info, + "exception_message": str(ex), + "exception_type": exception_type, + "traceback": traceback.format_tb(tb), + "linked_node": val, + "linked_node_inputs": prompt[o_id] + } + }] + validated[o_id] = (False, reasons, o_id) + return (False, None) + + return (True, val) + + +def validate_value(inputs, unique_id, x, val, info, obj_class): + type_input = info[0] + result_val = val + + try: + if type_input == "INT": + result_val = int(val) + if type_input == "FLOAT": + result_val = float(val) + if type_input == "STRING": + result_val = str(val) + except Exception as ex: + error = { + "type": "invalid_input_type", + "message": f"Failed to convert an input value to a {type_input} value", + "details": f"{x}, {val}, {ex}", + "extra_info": { + "input_name": x, + "input_config": info, + "received_value": val, + "exception_message": str(ex) + } + } + return (False, error) + + if len(info) > 1: + if "min" in info[1] and val < info[1]["min"]: + error = { + "type": "value_smaller_than_min", + "message": "Value {} smaller than min of {}".format(val, info[1]["min"]), + "details": f"{x}", + "extra_info": { + "input_name": x, + "input_config": info, + "received_value": val, + } + } + return (False, error) + if "max" in info[1] and val > info[1]["max"]: + error = { + "type": "value_bigger_than_max", + "message": "Value {} bigger than max of {}".format(val, info[1]["max"]), + "details": f"{x}", + "extra_info": { + "input_name": x, + "input_config": info, + "received_value": val, + } + } + return (False, error) + else: + # Validate combo widget + if isinstance(type_input, list): + if val not in type_input: + input_config = info + list_info = "" + + # Don't send back gigantic lists like if they're lots of + # scanned model filepaths + if len(type_input) > 20: + list_info = f"(list of length {len(type_input)})" + input_config = None + else: + list_info = str(type_input) + + error = { + "type": "value_not_in_list", + "message": "Value not in list", + "details": f"{x}: '{val}' not in {list_info}", + "extra_info": { + "input_name": x, + "input_config": input_config, + "received_value": val, + } + } + return (False, error) + + return (True, result_val) + + def validate_inputs(prompt, item, validated): unique_id = item if unique_id in validated: @@ -396,168 +549,84 @@ def validate_inputs(prompt, item, validated): val = inputs[x] info = required_inputs[x] - type_input = info[0] - if isinstance(val, list): - if len(val) != 2: - error = { - "type": "bad_linked_input", - "message": "Bad linked input, must be a length-2 list of [node_id, slot_index]", - "details": f"{x}", - "extra_info": { - "input_name": x, - "input_config": info, - "received_value": val - } + + input_type = None + if isinstance(val, dict): + input_type = val.get("type", None) + + if input_type not in ["link", "value"]: + error = { + "type": "bad_input_format", + "message": "Bad input format, must be a dictionary with 'type' set to 'link' or 'value'", + "details": f"{x}", + "extra_info": { + "input_name": x, + "input_config": info, + "received_value": val } - errors.append(error) + } + errors.append(error) + continue + + if input_type == "link": + result = validate_link(prompt, x, val, info, validated) + if result[0] is False: + valid = False + if result[1] is not None: + errors.append(result[1]) continue - o_id = val[0] - o_class_type = prompt[o_id]['class_type'] - r = nodes.NODE_CLASS_MAPPINGS[o_class_type].RETURN_TYPES - if r[val[1]] != type_input: - received_type = r[val[1]] - details = f"{x}, {received_type} != {type_input}" + inputs[x] = result[1] + + elif input_type == "value": + inner_val = val.get("value", None) + if inner_val is None: error = { - "type": "return_type_mismatch", - "message": "Return type mismatch between linked nodes", - "details": details, + "type": "bad_value_input", + "message": "Bad value input, must be a dictionary like { type: 'value', value: 42 }", + "details": f"{x}, {val}", "extra_info": { "input_name": x, "input_config": info, - "received_type": received_type, - "linked_node": val + "received_value": val, } } - errors.append(error) - continue - try: - r = validate_inputs(prompt, o_id, validated) - if r[0] is False: - # `r` will be set in `validated[o_id]` already - valid = False - continue - except Exception as ex: - typ, _, tb = sys.exc_info() - valid = False - exception_type = full_type_name(typ) - reasons = [{ - "type": "exception_during_inner_validation", - "message": "Exception when validating inner node", - "details": str(ex), - "extra_info": { - "input_name": x, - "input_config": info, - "exception_message": str(ex), - "exception_type": exception_type, - "traceback": traceback.format_tb(tb), - "linked_node": val - } - }] - validated[o_id] = (False, reasons, o_id) + return (False, error) + + result = validate_value(inputs, unique_id, x, inner_val, info, obj_class) + + if result[0] is False: + errors.append(result[1]) continue - else: - try: - if type_input == "INT": - val = int(val) - inputs[x] = val - if type_input == "FLOAT": - val = float(val) - inputs[x] = val - if type_input == "STRING": - val = str(val) - inputs[x] = val - except Exception as ex: + + inputs[x] = { "type": "value", "value": result[1] } + + if hasattr(obj_class, "VALIDATE_INPUTS"): + input_data_all = get_input_data(inputs, obj_class, unique_id) + #ret = obj_class.VALIDATE_INPUTS(**input_data_all) + ret = map_node_over_list(obj_class, input_data_all, "VALIDATE_INPUTS") + for i, r in enumerate(ret): + if r is not True: + details = "" + if r is not False: + details += str(r) + + input_data_formatted = {} + if input_data_all is not None: + input_data_formatted = {} + for name, inputList in input_data_all.items(): + input_data_formatted[name] = [format_value(x) for x in inputList] + error = { - "type": "invalid_input_type", - "message": f"Failed to convert an input value to a {type_input} value", - "details": f"{x}, {val}, {ex}", + "type": "custom_validation_failed", + "message": "Custom validation failed for node", + "details": details, "extra_info": { - "input_name": x, "input_config": info, - "received_value": val, - "exception_message": str(ex) + "received_inputs": input_data_formatted, } } errors.append(error) - continue - - if len(info) > 1: - if "min" in info[1] and val < info[1]["min"]: - error = { - "type": "value_smaller_than_min", - "message": "Value {} smaller than min of {}".format(val, info[1]["min"]), - "details": f"{x}", - "extra_info": { - "input_name": x, - "input_config": info, - "received_value": val, - } - } - errors.append(error) - continue - if "max" in info[1] and val > info[1]["max"]: - error = { - "type": "value_bigger_than_max", - "message": "Value {} bigger than max of {}".format(val, info[1]["max"]), - "details": f"{x}", - "extra_info": { - "input_name": x, - "input_config": info, - "received_value": val, - } - } - errors.append(error) - continue - - if hasattr(obj_class, "VALIDATE_INPUTS"): - input_data_all = get_input_data(inputs, obj_class, unique_id) - #ret = obj_class.VALIDATE_INPUTS(**input_data_all) - ret = map_node_over_list(obj_class, input_data_all, "VALIDATE_INPUTS") - for i, r in enumerate(ret): - if r is not True: - details = f"{x}" - if r is not False: - details += f" - {str(r)}" - - error = { - "type": "custom_validation_failed", - "message": "Custom validation failed for node", - "details": details, - "extra_info": { - "input_name": x, - "input_config": info, - "received_value": val, - } - } - errors.append(error) - continue - else: - if isinstance(type_input, list): - if val not in type_input: - input_config = info - list_info = "" - - # Don't send back gigantic lists like if they're lots of - # scanned model filepaths - if len(type_input) > 20: - list_info = f"(list of length {len(type_input)})" - input_config = None - else: - list_info = str(type_input) - - error = { - "type": "value_not_in_list", - "message": "Value not in list", - "details": f"{x}: '{val}' not in {list_info}", - "extra_info": { - "input_name": x, - "input_config": input_config, - "received_value": val, - } - } - errors.append(error) - continue if len(errors) > 0 or valid is not True: ret = (False, errors, unique_id) @@ -644,7 +713,7 @@ def validate_prompt(prompt): node_errors[node_id]["dependent_outputs"].append(o) print("Output will be ignored") - if len(good_outputs) == 0: + if len(good_outputs) == 0 or node_errors: errors_list = [] for o, errors in errors: for error in errors: diff --git a/nodes.py b/nodes.py index 6f05e4b77844..ae3c784bf179 100644 --- a/nodes.py +++ b/nodes.py @@ -1085,7 +1085,7 @@ def INPUT_TYPES(s): input_dir = folder_paths.get_input_directory() files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] return {"required": - {"image": (sorted(files), { "forceInput": True })}, + {"image": (sorted(files), )}, } CATEGORY = "image" @@ -1127,7 +1127,7 @@ def INPUT_TYPES(s): input_dir = folder_paths.get_input_directory() files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] return {"required": - {"images": (sorted(files), )}, + {"images": ("MULTIIMAGEUPLOAD", { "filepaths": sorted(files) } )}, } CATEGORY = "image" @@ -1135,7 +1135,6 @@ def INPUT_TYPES(s): RETURN_TYPES = ("IMAGE", "MASK") FUNCTION = "load_images" - INPUT_IS_LIST = True OUTPUT_IS_LIST = (True, True, ) def load_images(self, images): @@ -1437,6 +1436,7 @@ def expand_image(self, image, left, top, right, bottom, feathering): "PreviewImage": "Preview Image", "LoadImage": "Load Image", "LoadImageMask": "Load Image (as Mask)", + "LoadImageBatch": "Load Image Batch", "ImageScale": "Upscale Image", "ImageUpscaleWithModel": "Upscale Image (using Model)", "ImageInvert": "Invert Image", diff --git a/web/extensions/core/uploadImage.js b/web/extensions/core/uploadImage.js index e2ecfae861c9..3584364a3c29 100644 --- a/web/extensions/core/uploadImage.js +++ b/web/extensions/core/uploadImage.js @@ -10,9 +10,6 @@ app.registerExtension({ case "LoadImageMask": nodeData.input.required.upload = ["IMAGEUPLOAD"]; break; - case "LoadImageBatch": - nodeData.input.required.upload = ["MULTIIMAGEUPLOAD"]; - break; } }, }); diff --git a/web/scripts/app.js b/web/scripts/app.js index fd1186ab916f..f329ab1318bb 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -1110,20 +1110,21 @@ export class ComfyApp { for (const inputName in inputs) { const inputData = inputs[inputName]; const type = inputData[0]; - const inputShape = nodeData["input_is_list"] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE; + const options = inputData[1] || {}; + const inputShape = options.is_list ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE; if(inputData[1]?.forceInput) { this.addInput(inputName, type, { shape: inputShape }); } else { if (Array.isArray(type)) { // Enums - Object.assign(config, widgets.COMBO(this, inputName, inputData, nodeData, app) || {}); + Object.assign(config, widgets.COMBO(this, inputName, inputData, app) || {}); } else if (`${type}:${inputName}` in widgets) { // Support custom widgets by Type:Name - Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, nodeData, app) || {}); + Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {}); } else if (type in widgets) { // Standard type widgets - Object.assign(config, widgets[type](this, inputName, inputData, nodeData, app) || {}); + Object.assign(config, widgets[type](this, inputName, inputData, app) || {}); } else { // Node connection inputs this.addInput(inputName, type, { shape: inputShape }); @@ -1313,7 +1314,8 @@ export class ComfyApp { for (const i in widgets) { const widget = widgets[i]; if (!widget.options || widget.options.serialize !== false) { - inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(n, i) : widget.value; + const value = widget.serializeValue ? await widget.serializeValue(n, i) : widget.value; + inputs[widget.name] = { type: "value", value } } } } @@ -1333,7 +1335,11 @@ export class ComfyApp { } if (link) { - inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)]; + inputs[node.inputs[i].name] = { + type: "link", + origin_id: String(link.origin_id), + origin_slot: parseInt(link.origin_slot) + }; } } } @@ -1377,6 +1383,9 @@ export class ComfyApp { message += "\n" + nodeError.class_type + ":" for (const errorReason of nodeError.errors) { message += "\n - " + errorReason.message + ": " + errorReason.details + if (errorReason.extra_info?.traceback) { + message += "\n" + errorReason.extra_info.traceback.join("") + } } } return message diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 784e2740d8f8..76ca8c7556ec 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -268,7 +268,7 @@ const INT = (node, inputName, inputData) => { }; } -const STRING = (node, inputName, inputData, nodeData, app) => { +const STRING = (node, inputName, inputData, app) => { const defaultVal = inputData[1].default || ""; const multiline = !!inputData[1].multiline; @@ -279,14 +279,15 @@ const STRING = (node, inputName, inputData, nodeData, app) => { } } -const COMBO = (node, inputName, inputData, nodeData) => { +const COMBO = (node, inputName, inputData) => { const type = inputData[0]; let defaultValue = type[0]; - if (inputData[1] && inputData[1].default) { - defaultValue = inputData[1].default; + let options = inputData[1] || {} + if (options.default) { + defaultValue = options.default } - if (nodeData["input_is_list"]) { + if (options.is_list) { defaultValue = [defaultValue] const widget = node.addWidget("text", inputName, defaultValue, () => {}, { values: type }) widget.disabled = true; @@ -297,7 +298,7 @@ const COMBO = (node, inputName, inputData, nodeData) => { } } -const IMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { +const IMAGEUPLOAD = (node, inputName, inputData, app) => { const imageWidget = node.widgets.find((w) => w.name === "image"); let uploadWidget; @@ -412,8 +413,7 @@ const IMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { uploadWidget = node.addWidget("button", "choose file to upload", "image", () => { fileInput.value = null; fileInput.click(); - }); - uploadWidget.serialize = false; + }, { serialize: false }); // Add handler to check if an image is being dragged over our node node.onDragOver = function (e) { @@ -442,8 +442,14 @@ const IMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { return { widget: uploadWidget }; } -const MULTIIMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { - const imagesWidget = node.widgets.find((w) => w.name === "images"); +const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { + const imagesWidget = node.addWidget("text", inputName, inputData, () => {}) + + imagesWidget._filepaths = [] + if (inputData[1] && inputData[1].filepaths) { + imagesWidget._filepaths = inputData[1].filepaths + } + let uploadWidget; let clearWidget; @@ -534,11 +540,6 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { if (resp.status === 200) { const data = await resp.json(); - // Add the file as an option and update the widget value - if (!imagesWidget.options.values.includes(data.name)) { - imagesWidget.options.values.push(data.name); - } - if (updateNode) { imagesWidget.value.push(data.name) } @@ -573,14 +574,12 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, nodeData, app) => { uploadWidget = node.addWidget("button", "choose files to upload", "images", () => { fileInput.value = null; fileInput.click(); - }); - uploadWidget.serialize = false; + }, { serialize: false }); clearWidget = node.addWidget("button", "clear all uploads", "images", () => { imagesWidget.value = [] showImages(imagesWidget.value); - }); - clearWidget.serialize = false; + }, { serialize: false }); // Add handler to check if an image is being dragged over our node node.onDragOver = function (e) { From 4110e628f3c7e11151993d9ed8f1f6b52fa68757 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 14:59:53 -0500 Subject: [PATCH 03/11] Fix drag and drop --- web/scripts/widgets.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 76ca8c7556ec..71432ee0a767 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -444,6 +444,7 @@ const IMAGEUPLOAD = (node, inputName, inputData, app) => { const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { const imagesWidget = node.addWidget("text", inputName, inputData, () => {}) + imagesWidget.disabled = true; imagesWidget._filepaths = [] if (inputData[1] && inputData[1].filepaths) { @@ -592,14 +593,13 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { }; // On drop upload files - node.onDragDrop = function (e) { + node.onDragDrop = async (e) => { console.log("onDragDrop called"); let handled = false; - for (const file of e.dataTransfer.files) { - if (file.type.startsWith("image/")) { - uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one - handled = true; - } + const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith("image/")) + if (files) { + await uploadFiles(files, true); + handled = true; } return handled; From f9063bdfd36c41eab128d2884c23b91ec32860d2 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 16:44:41 -0500 Subject: [PATCH 04/11] Multiselect image dialog --- nodes.py | 32 +++++-- web/scripts/widgets.js | 208 ++++++++++++++++++++++++++++++++++++----- web/style.css | 151 ++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 30 deletions(-) diff --git a/nodes.py b/nodes.py index ae3c784bf179..73def68d2b9e 100644 --- a/nodes.py +++ b/nodes.py @@ -1125,9 +1125,12 @@ class LoadImageBatch: @classmethod def INPUT_TYPES(s): input_dir = folder_paths.get_input_directory() - files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + output_dir = folder_paths.get_output_directory() + file_dict = {} + file_dict["input"] = sorted(f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))) + file_dict["output"] = sorted(f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f))) return {"required": - {"images": ("MULTIIMAGEUPLOAD", { "filepaths": sorted(files) } )}, + {"images": ("MULTIIMAGEUPLOAD", { "filepaths": file_dict } )}, } CATEGORY = "image" @@ -1141,12 +1144,31 @@ def load_images(self, images): output_images = [] output_masks = [] - for i in range(len(images)): - image_path = folder_paths.get_annotated_filepath(images[i]) + loaded_images = [] + for idx in range(len(images)): + image_path = folder_paths.get_annotated_filepath(images[idx]) i = Image.open(image_path) i = ImageOps.exif_transpose(i) + loaded_images.append(i) + + min_size = float('inf') + min_image = None + + for image in loaded_images: + size = image.size[0] * image.size[1] + if size < min_size: + min_size = size + min_image = image + + for idx in range(len(images)): + i = loaded_images[idx] + + if i != min_image: + i = i.resize(min_image.size) + image = i.convert("RGB") + image = np.array(image).astype(np.float32) / 255.0 image = torch.from_numpy(image)[None,] if 'A' in i.getbands(): @@ -1258,7 +1280,6 @@ def upscale(self, image, upscale_method, width, height, crop): return (s,) class ImageInvert: - @classmethod def INPUT_TYPES(s): return {"required": { "image": ("IMAGE",)}} @@ -1274,7 +1295,6 @@ def invert(self, image): class ImagePadForOutpaint: - @classmethod def INPUT_TYPES(s): return { diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 71432ee0a767..2327a70158ef 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -442,38 +442,41 @@ const IMAGEUPLOAD = (node, inputName, inputData, app) => { return { widget: uploadWidget }; } +async function loadImageAsync(imageURL) { + return new Promise((resolve) => { + const e = new Image(); + e.setAttribute('crossorigin', 'anonymous'); + e.addEventListener("load", () => { resolve(e); }); + e.src = imageURL; + return e; + }); +} + const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { const imagesWidget = node.addWidget("text", inputName, inputData, () => {}) imagesWidget.disabled = true; - imagesWidget._filepaths = [] + imagesWidget._filepaths = {} if (inputData[1] && inputData[1].filepaths) { imagesWidget._filepaths = inputData[1].filepaths } - let uploadWidget; - let clearWidget; - - function showImages(names) { + async function showImages(names) { node.imgs = [] for (const name of names) { - const img = new Image(); - img.onload = () => { - // TODO await this? - node.imgs.push(img) - node.imageIndex = null; - node.setSizeForImage?.(); - app.graph.setDirtyCanvas(true); - }; let folder_separator = name.lastIndexOf("/"); let subfolder = ""; if (folder_separator > -1) { subfolder = name.substring(0, folder_separator); name = name.substring(folder_separator + 1); } - img.src = `/view?filename=${name}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}`; + const src = `/view?filename=${name}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}`; + const img = await loadImageAsync(src); + node.imgs.push(img) + node.imageIndex = null; node.setSizeForImage?.(); + app.graph.setDirtyCanvas(true); } } @@ -512,19 +515,20 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { // Add our own callback to the combo widget to render an image when it changes const cb = node.callback; - imagesWidget.callback = function () { - showImages(imagesWidget.value); - if (cb) { - return cb.apply(this, arguments); - } + imagesWidget.callback = () => { + showImages(imagesWidget.value).then(() => { + if (cb) { + return cb.apply(this, arguments); + } + }) }; // On load if we have a value then render the image // The value isnt set immediately so we need to wait a moment // No change callbacks seem to be fired on initial setting of the value - requestAnimationFrame(() => { + requestAnimationFrame(async () => { if (Array.isArray(imagesWidget.value) && imagesWidget.value.length > 0) { - showImages(imagesWidget.value); + await showImages(imagesWidget.value); } }); @@ -553,7 +557,7 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { } if (updateNode) { - showImages(imagesWidget.value); + await showImages(imagesWidget.value); } } @@ -572,12 +576,168 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { document.body.append(fileInput); // Create the button widget for selecting the files - uploadWidget = node.addWidget("button", "choose files to upload", "images", () => { + const pickWidget = node.addWidget("button", "pick files from ComfyUI folders", "images", () => { + const graphCanvas = LiteGraph.LGraphCanvas.active_canvas + if (graphCanvas == null) + return; + + const panel = graphCanvas.createPanel("Pick Images", { closable: true }); + panel.node = node; + panel.classList.add("multiimageupload_dialog"); + const swap = (arr, i, j) => { + const temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + + const rootHtml = ` +
+
+
+
+`; + const rootElem = panel.addHTML(rootHtml, "root"); + const left = rootElem.querySelector('.left') + const right = rootElem.querySelector('.right') + + const previewHtml = ` + +
+ + +
+
+ +
`; + const previewElem = document.createElement("div"); + previewElem.innerHTML = previewHtml; + previewElem.className = "multiimageupload_preview"; + right.appendChild(previewElem); + + const folderTypeSel = previewElem.querySelector('.folder-type'); + const imagePathSel = previewElem.querySelector('.image-path'); + const imagePreview = previewElem.querySelector('.image-preview'); + + imagePathSel.addEventListener("change", (event) => { + const filename = event.target.value; + const type = folderTypeSel.value; + imagePreview.src = `/view?filename=${filename}&type=${type}` + }); + + folderTypeSel.addEventListener("change", (event) => { + imagePathSel.innerHTML = ""; + const filepaths = imagesWidget._filepaths[event.target.value]; + if (filepaths == null) + return; + + for (const filepath of filepaths) { + const filename = filepath.split('\\').pop().split('/').pop(); + const opt = document.createElement('option'); + opt.value = filepath + opt.innerHTML = filename + imagePathSel.appendChild(opt); + } + + imagePathSel.value = filepaths[0] + imagePathSel.dispatchEvent(new Event('change')); + }); + + folderTypeSel.value = "output"; + folderTypeSel.dispatchEvent(new Event('change')); + + const addButton = previewElem.querySelector('.add-image'); + addButton.addEventListener("click", async (event) => { + const filename = imagePathSel.value; + const type = folderTypeSel.value; + const value = `${filename} [${type}]`; + imagesWidget._real_value.push(value) + imagesWidget.value = imagesWidget._real_value + await showImages(imagesWidget.value); + inner_refresh(); + }) + + const clearButton = panel.addButton("Clear", () => { + imagesWidget.value = [] + showImages(imagesWidget.value); + inner_refresh(); + }) + clearButton.style.display = "block"; + clearButton.style.marginLeft = "initial"; + + const inner_refresh = () => { + left.innerHTML = "" + graphCanvas.draw(true); + + if (node.imgs) { + for (let i = 0; i < imagesWidget.value.length; i++) { + const imagePath = imagesWidget.value[i]; + const img = node.imgs[i]; + if (!imagePath || !img) + continue; + const html = ` + +
+ + + + + +
`; + const elem = document.createElement("div"); + elem.innerHTML = html; + elem.className = "multiimageupload_image"; + left.appendChild(elem); + + elem.dataset["imagePath"] = imagePath + elem.dataset["imageIndex"] = "" + i; + elem.querySelector(".image-path").innerText = imagePath + elem.querySelector(".type").innerText = "" + elem.querySelector(".delete").addEventListener("click", function(e) { + const imageIndex = +this.parentNode.parentNode.dataset["imageIndex"] + imagesWidget._real_value.splice(imageIndex, 1) + imagesWidget.value = imagesWidget._real_value + node.imgs.splice(imageIndex, 1); + node.imageIndex = null; + node.setSizeForImage?.(); + inner_refresh(); + }); + const move_up = elem.querySelector(".move_up"); + move_up.disabled = i <= 0; + move_up.addEventListener("click", function(e) { + const imageIndex = +this.parentNode.parentNode.dataset["imageIndex"] + if (imageIndex < 0) + return; + swap(imagesWidget.value, imageIndex, imageIndex - 1); + swap(node.imgs, imageIndex, imageIndex - 1); + inner_refresh(); + }); + const move_down = elem.querySelector(".move_down") + move_down.disabled = i >= imagesWidget.value.length - 1; + move_down.addEventListener("click", function(e) { + const imageIndex = +this.parentNode.parentNode.dataset["imageIndex"] + if (imageIndex > imagesWidget.value.length - 1) + return; + swap(imagesWidget.value, imageIndex, imageIndex + 1); + swap(node.imgs, imageIndex, imageIndex + 1); + inner_refresh(); + }); + } + } + } + + inner_refresh(); + document.body.appendChild(panel); + }, { serialize: false }); + + const uploadWidget = node.addWidget("button", "choose files to upload", "images", () => { fileInput.value = null; fileInput.click(); }, { serialize: false }); - clearWidget = node.addWidget("button", "clear all uploads", "images", () => { + const clearWidget = node.addWidget("button", "clear all uploads", "images", () => { imagesWidget.value = [] showImages(imagesWidget.value); }, { serialize: false }); diff --git a/web/style.css b/web/style.css index 47571a16ee5f..7908f8df0cec 100644 --- a/web/style.css +++ b/web/style.css @@ -356,3 +356,154 @@ button.comfy-queue-btn { color: var(--input-text); filter: brightness(50%); } + +.litegraph .dialog.multiimageupload_dialog { + font-family: Arial; + display: inline-block; + text-align: right; + color: #AAA; + top: 10%; + left: calc(50% - 400px); + width: 800px; + height: calc(100% - (10% * 2)); + max-width: initial; + min-width: 200px; + max-height: initial; + min-height: 20px; + padding: 4px; + margin: auto; + overflow: hidden; + cursor: pointer; + border-radius: 3px; +} + +.litegraph .dialog select { + margin-right: 20px; + padding-left: 4px; + font-size: 12pt; + background-color: #1c1c1c; + color: #ccc; + border: 0; +} + +.multiimageupload_dialog { + position: fixed; + padding: 4px; +} + +.multiimageupload_dialog .root { + display: flex; + flex-direction: row; + height: 100%; +} + +.multiimageupload_dialog .left { + width: 50%; + overflow-y: auto; + border-right: 2px solid #555; +} + +.multiimageupload_dialog .right { + width: 50%; +} + +.multiimageupload_preview img { + width: 300px; + height: 300px; + object-fit: contain; + display: block; + margin-left: 16px; + margin-bottom: 8px; +} + +.multiimageupload_preview .bar { + display: flex; + padding: 0.5rem 0rem 0.5rem 1rem; +} + +.multiimageupload_preview select { + max-width: 250px; +} + +.multiimageupload_dialog :disabled { + opacity: 0.5; + cursor: default; +} + +.multiimageupload_dialog:hover { + background-color: #333; +} + +.multiimageupload_dialog.extra { + margin-top: 8px; +} + +.multiimageupload_dialog span.name { + font-size: 1.3em; + padding-left: 4px; +} + +.multiimageupload_dialog span.type { + opacity: 0.5; + margin-right: 20px; + padding-left: 4px; +} + +.multiimageupload_dialog span.label { + display: inline-block; + width: 60px; + padding: 0px 10px; +} + +.multiimageupload_dialog input { + width: 140px; + color: #999; + background-color: #1A1A1A; + border-radius: 4px; + border: 0; + margin-right: 10px; + padding: 4px; + padding-left: 10px; +} + +.multiimageupload_dialog button { + background-color: #1c1c1c; + color: #aaa; + border: 0; + border-radius: 2px; + padding: 4px 10px; + cursor: pointer; +} + +.multiimageupload_dialog.extra { + color: #ccc; +} + +.multiimageupload_dialog.extra input { + background-color: #111; +} + +.multiimageupload_image { + padding: 4px; + display: flex; + flex-direction: column; +} + +.multiimageupload_image .bar { + padding: 4px; + display: flex; + flex-direction: row; +} + +.multiimageupload_image .bar span { + margin: auto; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.multiimageupload_image img { + width: 300px; + height: 300px; + object-fit: contain; +} From 569c22673f673693f57621cd8ef261ba285e02e8 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 17:03:35 -0500 Subject: [PATCH 05/11] Fix dialog Z order --- web/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/style.css b/web/style.css index 7908f8df0cec..113fde3c886b 100644 --- a/web/style.css +++ b/web/style.css @@ -357,6 +357,10 @@ button.comfy-queue-btn { filter: brightness(50%); } +.litegraph .dialog { + z-index: 1; +} + .litegraph .dialog.multiimageupload_dialog { font-family: Arial; display: inline-block; From c5ea042907fbc430beff95b0566765d45069ae3e Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 17:04:01 -0500 Subject: [PATCH 06/11] `torch.cat()` everything --- nodes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nodes.py b/nodes.py index 73def68d2b9e..953e63555ddc 100644 --- a/nodes.py +++ b/nodes.py @@ -1138,8 +1138,6 @@ def INPUT_TYPES(s): RETURN_TYPES = ("IMAGE", "MASK") FUNCTION = "load_images" - OUTPUT_IS_LIST = (True, True, ) - def load_images(self, images): output_images = [] output_masks = [] @@ -1180,7 +1178,7 @@ def load_images(self, images): output_images.append(image) output_masks.append(mask) - return (output_images, output_masks, ) + return (torch.cat(output_images), torch.cat(output_masks), ) @classmethod def IS_CHANGED(s, images): From 2c146b810b02e59f845ee1bc1b3c3c48f144cb43 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 29 May 2023 17:14:43 -0500 Subject: [PATCH 07/11] Fix litegraph dialog font family --- web/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/style.css b/web/style.css index 113fde3c886b..ccde802e1c69 100644 --- a/web/style.css +++ b/web/style.css @@ -359,10 +359,10 @@ button.comfy-queue-btn { .litegraph .dialog { z-index: 1; + font-family: Arial; } .litegraph .dialog.multiimageupload_dialog { - font-family: Arial; display: inline-block; text-align: right; color: #AAA; From ec9ff003bfe9b67544b3916973325c06abac683b Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Thu, 1 Jun 2023 12:16:20 -0500 Subject: [PATCH 08/11] Adjust batch image prompt --- web/scripts/widgets.js | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 2327a70158ef..12c56d33e995 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -581,9 +581,15 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { if (graphCanvas == null) return; - const panel = graphCanvas.createPanel("Pick Images", { closable: true }); - panel.node = node; - panel.classList.add("multiimageupload_dialog"); + if (imagesWidget.panel != null) + return + + imagesWidget.panel = graphCanvas.createPanel("Pick Images", { closable: true }); + imagesWidget.panel.onClose = () => { + imagesWidget.panel = null; + } + imagesWidget.panel.node = node; + imagesWidget.panel.classList.add("multiimageupload_dialog"); const swap = (arr, i, j) => { const temp = arr[i]; arr[i] = arr[j]; @@ -596,7 +602,7 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => {
`; - const rootElem = panel.addHTML(rootHtml, "root"); + const rootElem = imagesWidget.panel.addHTML(rootHtml, "root"); const left = rootElem.querySelector('.left') const right = rootElem.querySelector('.right') @@ -659,13 +665,23 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { inner_refresh(); }) - const clearButton = panel.addButton("Clear", () => { + imagesWidget.panel.footer.style.display = "flex"; + + const clearButton = imagesWidget.panel.addButton("Clear", () => { imagesWidget.value = [] showImages(imagesWidget.value); inner_refresh(); }) clearButton.style.display = "block"; clearButton.style.marginLeft = "initial"; + clearButton.style.height = "28px"; + + const okButton = imagesWidget.panel.addButton("OK", () => { + imagesWidget.panel.close(); + }) + okButton.style.display = "block"; + okButton.style.height = "28px"; + okButton.style.marginLeft = "auto"; const inner_refresh = () => { left.innerHTML = "" @@ -729,7 +745,7 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { } inner_refresh(); - document.body.appendChild(panel); + document.body.appendChild(imagesWidget.panel); }, { serialize: false }); const uploadWidget = node.addWidget("button", "choose files to upload", "images", () => { From 341fe22b92f38f5164818c4553574c0b4d7b1e10 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Fri, 2 Jun 2023 10:49:34 -0500 Subject: [PATCH 09/11] Merge LoadImageBatch into LoadImage --- nodes.py | 44 ------------------------------ web/extensions/core/uploadImage.js | 1 - web/scripts/widgets.js | 5 ++++ 3 files changed, 5 insertions(+), 45 deletions(-) diff --git a/nodes.py b/nodes.py index 953e63555ddc..2bc76a5b3f9f 100644 --- a/nodes.py +++ b/nodes.py @@ -1080,48 +1080,6 @@ def INPUT_TYPES(s): } class LoadImage: - @classmethod - def INPUT_TYPES(s): - input_dir = folder_paths.get_input_directory() - files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] - return {"required": - {"image": (sorted(files), )}, - } - - CATEGORY = "image" - - RETURN_TYPES = ("IMAGE", "MASK") - FUNCTION = "load_image" - def load_image(self, image): - image_path = folder_paths.get_annotated_filepath(image) - i = Image.open(image_path) - i = ImageOps.exif_transpose(i) - image = i.convert("RGB") - image = np.array(image).astype(np.float32) / 255.0 - image = torch.from_numpy(image)[None,] - if 'A' in i.getbands(): - mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 - mask = 1. - torch.from_numpy(mask) - else: - mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") - return (image, mask) - - @classmethod - def IS_CHANGED(s, image): - image_path = folder_paths.get_annotated_filepath(image) - m = hashlib.sha256() - with open(image_path, 'rb') as f: - m.update(f.read()) - return m.digest().hex() - - @classmethod - def VALIDATE_INPUTS(s, image): - if not folder_paths.exists_annotated_filepath(image): - return "Invalid image file: {}".format(image) - - return True - -class LoadImageBatch: @classmethod def INPUT_TYPES(s): input_dir = folder_paths.get_input_directory() @@ -1372,7 +1330,6 @@ def expand_image(self, image, left, top, right, bottom, feathering): "PreviewImage": PreviewImage, "LoadImage": LoadImage, "LoadImageMask": LoadImageMask, - "LoadImageBatch": LoadImageBatch, "ImageScale": ImageScale, "ImageInvert": ImageInvert, "ImagePadForOutpaint": ImagePadForOutpaint, @@ -1454,7 +1411,6 @@ def expand_image(self, image, left, top, right, bottom, feathering): "PreviewImage": "Preview Image", "LoadImage": "Load Image", "LoadImageMask": "Load Image (as Mask)", - "LoadImageBatch": "Load Image Batch", "ImageScale": "Upscale Image", "ImageUpscaleWithModel": "Upscale Image (using Model)", "ImageInvert": "Invert Image", diff --git a/web/extensions/core/uploadImage.js b/web/extensions/core/uploadImage.js index 3584364a3c29..614926f12882 100644 --- a/web/extensions/core/uploadImage.js +++ b/web/extensions/core/uploadImage.js @@ -6,7 +6,6 @@ app.registerExtension({ name: "Comfy.UploadImage", async beforeRegisterNodeDef(nodeType, nodeData, app) { switch (nodeData.name) { - case "LoadImage": case "LoadImageMask": nodeData.input.required.upload = ["IMAGEUPLOAD"]; break; diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 12c56d33e995..1b7bbfe9909e 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -453,6 +453,11 @@ async function loadImageAsync(imageURL) { } const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { + console.error("LOADDATA", node, inputName, inputData) + if (typeof inputData === "string") { + inputData = [inputData] + } + const imagesWidget = node.addWidget("text", inputName, inputData, () => {}) imagesWidget.disabled = true; From d4e73878e2467c561ecdb82c465d099010a8de33 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Fri, 2 Jun 2023 10:56:32 -0500 Subject: [PATCH 10/11] Support previously saved workflows --- web/scripts/widgets.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 1b7bbfe9909e..308e8ad61976 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -453,11 +453,6 @@ async function loadImageAsync(imageURL) { } const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { - console.error("LOADDATA", node, inputName, inputData) - if (typeof inputData === "string") { - inputData = [inputData] - } - const imagesWidget = node.addWidget("text", inputName, inputData, () => {}) imagesWidget.disabled = true; @@ -488,6 +483,9 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { var default_value = imagesWidget.value; Object.defineProperty(imagesWidget, "value", { set : function(value) { + if (typeof value === "string") { + value = [value] + } this._real_value = value; }, From ec8678c45afc3b5d422cca54063d8618023ad171 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Thu, 8 Jun 2023 20:27:08 -0500 Subject: [PATCH 11/11] Load image batch improvements - Uses filter list behavior for LiteGraph context menu - "Replace" button - Can select from combo widget on node directly --- web/scripts/widgets.js | 79 +++++++++++++++++++++++++++--------------- web/style.css | 14 ++++++++ 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 308e8ad61976..53b3d5c9b050 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -453,14 +453,20 @@ async function loadImageAsync(imageURL) { } const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { - const imagesWidget = node.addWidget("text", inputName, inputData, () => {}) - imagesWidget.disabled = true; + let filepaths = { input: [], output: [] } - imagesWidget._filepaths = {} if (inputData[1] && inputData[1].filepaths) { - imagesWidget._filepaths = inputData[1].filepaths + filepaths = inputData[1].filepaths } + const update = function(v) { + this.value = v + } + + const imagesWidget = node.addWidget("combo", inputName, inputData, update, { values: filepaths["input"] }) + imagesWidget._filepaths = filepaths + imagesWidget._entries = filepaths["input"] + async function showImages(names) { node.imgs = [] @@ -616,10 +622,11 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { - + -
+
+
`; const previewElem = document.createElement("div"); previewElem.innerHTML = previewHtml; @@ -628,46 +635,64 @@ const MULTIIMAGEUPLOAD = (node, inputName, inputData, app) => { const folderTypeSel = previewElem.querySelector('.folder-type'); const imagePathSel = previewElem.querySelector('.image-path'); + const imagePathText = previewElem.querySelector('.image-path-text'); const imagePreview = previewElem.querySelector('.image-preview'); - imagePathSel.addEventListener("change", (event) => { - const filename = event.target.value; - const type = folderTypeSel.value; - imagePreview.src = `/view?filename=${filename}&type=${type}` - }); - folderTypeSel.addEventListener("change", (event) => { - imagePathSel.innerHTML = ""; const filepaths = imagesWidget._filepaths[event.target.value]; - if (filepaths == null) - return; - - for (const filepath of filepaths) { - const filename = filepath.split('\\').pop().split('/').pop(); - const opt = document.createElement('option'); - opt.value = filepath - opt.innerHTML = filename - imagePathSel.appendChild(opt); + imagesWidget._entries = filepaths + imagePathText.innerHTML = filepaths[0]; + imagePreview.src = `/view?filename=${filepaths[0]}&type=${event.target.value}` + }); + + imagePathSel.addEventListener("click", (event) => { + const type = folderTypeSel.value; + const filepaths = imagesWidget._filepaths[folderTypeSel.value]; + const entries = imagesWidget._entries + + const innerClicked = (v, _options, e, prev) => { + const filename = v; + imagePathText.innerHTML = filename; + imagePreview.src = `/view?filename=${filename}&type=${type}` } - imagePathSel.value = filepaths[0] - imagePathSel.dispatchEvent(new Event('change')); + new LiteGraph.ContextMenu(entries, { + event, + callback: innerClicked, + node, + className: "dark" // required for contextMenuFilter.js to kick in + }); }); - folderTypeSel.value = "output"; + folderTypeSel.value = "input"; folderTypeSel.dispatchEvent(new Event('change')); const addButton = previewElem.querySelector('.add-image'); addButton.addEventListener("click", async (event) => { - const filename = imagePathSel.value; + const filename = imagePathText.innerHTML; const type = folderTypeSel.value; - const value = `${filename} [${type}]`; + let value = filename; + if (type !== "input") + value += ` [${type}]` imagesWidget._real_value.push(value) imagesWidget.value = imagesWidget._real_value await showImages(imagesWidget.value); inner_refresh(); }) + const replaceButton = previewElem.querySelector('.replace-image'); + replaceButton.addEventListener("click", async (event) => { + const filename = imagePathText.innerHTML; + const type = folderTypeSel.value; + let value = filename; + if (type !== "input") + value += ` [${type}]` + imagesWidget._real_value = [value] + imagesWidget.value = imagesWidget._real_value + await showImages(imagesWidget.value); + inner_refresh(); + }) + imagesWidget.panel.footer.style.display = "flex"; const clearButton = imagesWidget.panel.addButton("Clear", () => { diff --git a/web/style.css b/web/style.css index ccde802e1c69..fd44f25df361 100644 --- a/web/style.css +++ b/web/style.css @@ -381,6 +381,15 @@ button.comfy-queue-btn { border-radius: 3px; } +.litegraph .dialog.multiimageupload_dialog .image-path-text { + max-width: 250px; + overflow: hidden; + white-space: nowrap; + display: block; + text-overflow: ellipsis; + text-align: left; +} + .litegraph .dialog select { margin-right: 20px; padding-left: 4px; @@ -471,6 +480,7 @@ button.comfy-queue-btn { } .multiimageupload_dialog button { + height: 28px; background-color: #1c1c1c; color: #aaa; border: 0; @@ -479,6 +489,10 @@ button.comfy-queue-btn { cursor: pointer; } +.multiimageupload_dialog .actions button { + margin-right: 10px; +} + .multiimageupload_dialog.extra { color: #ccc; }