From 36df15595f0fe69a6c235cdb607dbfacde86173a Mon Sep 17 00:00:00 2001 From: Beamhi Date: Sat, 24 Aug 2024 08:10:27 -0700 Subject: [PATCH] first commit --- .gitignore | 164 +++++++++ LICENSE | 201 +++++++++++ README.md | 98 ++++++ __init__.py | 25 ++ core/dispatch.py | 160 +++++++++ core/fetch.py | 97 ++++++ core/utils.py | 23 ++ fonts/Roboto-Regular.ttf | Bin 0 -> 129584 bytes mass-process/job.example.yaml | 28 ++ mass-process/readme.md | 9 + mass-process/server.py | 159 +++++++++ nodes/advanced.py | 371 ++++++++++++++++++++ nodes/images.py | 127 +++++++ nodes/latents.py | 629 ++++++++++++++++++++++++++++++++++ nodes/simple.py | 289 ++++++++++++++++ nodes/workflows.py | 211 ++++++++++++ requirements.txt | 1 + 17 files changed, 2592 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 core/dispatch.py create mode 100644 core/fetch.py create mode 100644 core/utils.py create mode 100644 fonts/Roboto-Regular.ttf create mode 100644 mass-process/job.example.yaml create mode 100644 mass-process/readme.md create mode 100644 mass-process/server.py create mode 100644 nodes/advanced.py create mode 100644 nodes/images.py create mode 100644 nodes/latents.py create mode 100644 nodes/simple.py create mode 100644 nodes/workflows.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..933c844 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +mass-process/output +mass-process/image +mass-process/*.png + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..251a8a5 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# ComfyUI_NetDist Plus +Run ComfyUI workflows on multiple local GPUs/networked machines with options to edit the json values within comfyui. +# Credits +[City96](https://github.com/city96); without the base netdist repo, I wouldn't have attempted this. +[EventStationAI](https://www.eventstation.ai/); for some GPU support. +[Claude](https://console.anthropic.com/dashboard); what do I do next? Can you debug this error? +[Ogkai](https://civitai.com/user/ogkai_1111); Thanks for encouraging me to start pushing stuffs I make or modify. + + + +[NetDist_2xspeed.webm](https://github.com/city96/ComfyUI_NetDist/assets/125218114/b7ec2fcf-1e51-4b05-ad62-355da2a1bf6d) + +## Install instructions: +There is currently a single external requirement, which is the `requests` library. +``` +pip install requests +``` + +To install, simply clone into the custom nodes folder. +``` +git clone https://github.com/city96/ComfyUI_NetDist ComfyUI/custom_nodes/ComfyUI_NetDist +``` + +## Usage + +### Local Remote control +You will need at least two different ComfyUI instances. You can use two local GPUs by setting different `--port [port]` and `--cuda-device [number]` launch arguments. You'll most likely want `--port 8288 --cuda-device 1` + +#### Simple dual-GPU + +This is the simplest setup for people who have 2 GPUs or two separate PCs. It only requires two nodes to work. + +You can set the local/remote batch size, as well as when the node should trigger (set it to 'always' if it isn't getting executed - i.e. you changed a sampler setting but not the seed.) + +If you're running your second instance on a different PC, add `--listen` to your launch arguments and set the correct remote IP (open a terminal window and check with `ipconfig` on windows or `ip a` on linux). + +The `FetchRemote` ('Fetch from remote') node takes an image input. This should be your final image than you want to get back from your second instance (make sure not to route it back into itself). This node will wait for the second image to be generated (there's currently no preview/progress bar). + +Workflow JSON: [NetDistSimple.json](https://github.com/city96/ComfyUI_NetDist/files/13825326/NetDistSimple.json) + +![NetDistSimple](https://github.com/city96/ComfyUI_NetDist/assets/125218114/dce5a155-2ffa-4979-b184-03de168beecb) + +#### Simple multi-machine + +You can kind of scale the example above by connecting more of the simple queue nodes together, but the seed is a bit jank and you can get duplicate images if you try and reuse it. I guess just set the seed to randomized on both. + +![NetDistMulti](https://github.com/city96/ComfyUI_NetDist/assets/125218114/2a0358aa-ab8e-47e2-82a2-7a27a17d0130) + +#### Advanced + +This is mostly meant for more "advanced" setups with more than two GPUs. It allows easier per-batch overrides as well as setting a default batch size. + +It also allows using a workflow JSON as an input. To allow any workflow to run, the final image can be set to "any" instead of the default "final_image" (which would require the `FetchRemote` node to be in the workflow). + +I have nodes to save/load the workflows, but ideally there would be some nodes to also edit them - search and replace seed, etc. PRs welcome ;P + +Workflow JSON: [NetDistAdvancedV2.json](https://github.com/city96/ComfyUI_NetDist/files/13843005/NetDistAdvancedV2.json) + +![NetDistAdvanced](https://github.com/city96/ComfyUI_NetDist/assets/125218114/851c1ee6-edcf-4489-bab1-92ab9c5ef15e) + +(This needs a fake image input to trigger, you can just give it a blank image). + +![NetDistSaved](https://github.com/city96/ComfyUI_NetDist/assets/125218114/a39b5117-af1b-4f2c-a94e-5a330acc8ea4) + +### Remote images +The `LoadImageUrl` ('Load Image (URL)') Node acts just like the normal 'Load Image' node. + +The `SaveImageUrl` ('Save Image (URL)') Node sends a POST request to the target URL with a json containing the images. +- The filenames are the keys. +- The values are the base64 encoded PNG images (optionally with the `data:image/png;base64` prefix). +- The filenames are **not** guaranteed to be unique across batches since they aren't saved locally. You should handle this server-side. +- No data is written to disk on the server. + +### Remote latents + +This node pack has a set of nodes which should (in theory) allow you to pass latents between the nodes seamlessly. A node to save the input latent as a `.npy` file is provided. This node also returns the filename of the saved latent, which can then be loaded by the other instance. + +To load a latent from the other instance, you can plug the filename into this URL: + +``` +# change the filename with a string replacement node. +http://127.0.0.1:8188/view?filename=ComfyUI_00001_.latent&type=output` +# To load them from the input folder instead, change type to 'input' +http://127.0.0.1:8188/view?filename=TestLatent.npy&type=input +``` + +The `LoadLatentNumpy` node can also load the default safetensor latents, the npy ones (simple numpy file containing just the latent in the standard torch format) as well as the sd_scripts npz cache files. + +![LatentSave](https://github.com/city96/ComfyUI_NetDist/assets/125218114/cd68d8dc-bd96-4018-82c9-400337fc5f80) + +### Things you probably shouldn't do: +- Queue a workflow on the same remote worker multiple times from the same client. +- ~~Expect this to work smoothly.~~ + +## Roadmap +- Fix some edge cases, like linux controlling windows (`os.sep` mismatch). +- Better workflow editing for static workflows. +- Handle multiple separate image output nodes. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a706110 --- /dev/null +++ b/__init__.py @@ -0,0 +1,25 @@ +# only import if running as a custom node +try: + import comfy.utils +except ImportError: + pass +else: + NODE_CLASS_MAPPINGS = {} + + from .nodes.simple import NODE_CLASS_MAPPINGS as NetNodes + NODE_CLASS_MAPPINGS.update(NetNodes) + + from .nodes.advanced import NODE_CLASS_MAPPINGS as AdvNodes + NODE_CLASS_MAPPINGS.update(AdvNodes) + + from .nodes.images import NODE_CLASS_MAPPINGS as ImgNodes + NODE_CLASS_MAPPINGS.update(ImgNodes) + + from .nodes.latents import NODE_CLASS_MAPPINGS as LatNodes + NODE_CLASS_MAPPINGS.update(LatNodes) + + from .nodes.workflows import NODE_CLASS_MAPPINGS as WrkNodes + NODE_CLASS_MAPPINGS.update(WrkNodes) + + NODE_DISPLAY_NAME_MAPPINGS = {k:v.TITLE for k,v in NODE_CLASS_MAPPINGS.items()} + __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/core/dispatch.py b/core/dispatch.py new file mode 100644 index 0000000..01d677c --- /dev/null +++ b/core/dispatch.py @@ -0,0 +1,160 @@ +import os +import time +import json +import torch +import random +import requests +import numpy as np +from PIL import Image +from copy import deepcopy + +from .utils import clean_url, get_client_id + +def clear_remote_queue(remote_url): + r = requests.get(f"{remote_url}/queue", timeout=4) + r.raise_for_status() + queue = r.json() + + to_cancel = [] + client_id = get_client_id() + for k in queue.get("queue_pending", []): + if k[3].get("client_id") == client_id: + to_cancel.append(k[1]) # job UUID + r = requests.post( + f"{remote_url}/queue", + json = {"delete" : to_cancel}, + timeout = 4, + ) + r.raise_for_status() + + for k in queue.get("queue_running", []): + if k[3].get("client_id") == client_id: + r = requests.post( + f"{remote_url}/interrupt", + json = {}, + timeout = 4, + ) + r.raise_for_status() + break + +def get_remote_os(remote_url): + url = f"{remote_url}/system_stats" + r = requests.get(url, timeout=4) + r.raise_for_status() + data = r.json() + return data["system"]["os"] + +def get_output_nodes(remote_url): + # I'm 90% sure this could just use the + # list from the host but better safe than sorry + url = f"{remote_url}/object_info" + r = requests.get(url, timeout=4) + r.raise_for_status() + data = r.json() + out = [k for k, v in data.items() if v.get("output_node")] + return out + + +def dispatch_to_remote(remote_url, prompt, job_id=f"{get_client_id()}-unknown", remote_params=[], outputs="final_image"): + ### PROMPT LOGIC ### + prompt = deepcopy(prompt) + to_del = [] + + def recursive_node_deletion(start_node): + target_nodes = [start_node] + if start_node not in to_del: + to_del.append(start_node) + while len(target_nodes) > 0: + new_targets = [] + for target in target_nodes: + for node in prompt.keys(): + inputs = prompt[node].get("inputs") + if not inputs: + continue + for i in inputs.values(): + if type(i) == list: + if len(i) > 0 and i[0] in to_del: + if node not in to_del: + to_del.append(node) + new_targets.append(node) + target_nodes += new_targets + target_nodes.remove(target) + + # find current node and disable all others + output_src = None + for i in prompt.keys(): + if prompt[i]["class_type"].startswith("RemoteQueue"): + if clean_url(prompt[i]["inputs"]["remote_url"]) == remote_url: + prompt[i]["inputs"]["enabled"] = "remote" + output_src = i + # Apply remote parameters + for param, value, nodeid in remote_params: + if nodeid: + for node_key, node_data in prompt.items(): + if node_key == nodeid: + if param in node_data.get("inputs", {}): + prompt[node_key]["inputs"][param] = value + break + else: + if param in prompt[i]["inputs"]: + prompt[i]["inputs"][param] = value + else: + # If the parameter doesn't exist in the node's inputs, + # we need to find where to apply it in the prompt + for node_key, node_data in prompt.items(): + if param in node_data.get("inputs", {}): + prompt[node_key]["inputs"][param] = value + break + else: + prompt[i]["inputs"]["enabled"] = "false" + + banned = [] if outputs == "any" else ["PreviewImage", "SaveImage"] # get_output_nodes(remote_url) + output = None + for i in prompt.keys(): + # only leave current fetch but replace with PreviewImage + if prompt[i]["class_type"] == "FetchRemote": + if prompt[i]["inputs"]["remote_info"][0] == output_src: + output = { + "inputs": {"images": prompt[i]["inputs"]["final_image"]}, + "class_type": 'PreviewImage', + "final_output": True, # might allow multiple outputs with an ID? + } + recursive_node_deletion(i) + # do not save output on remote + if prompt[i]["class_type"] in banned: + recursive_node_deletion(i) + if output: + prompt[str(max([int(x) for x in prompt.keys()])+1)] = output + for i in to_del: del prompt[i] + + ### OS LOGIC ### + sep_remote = "\\" if get_remote_os(remote_url) == "nt" else "/" + sep_local = "\\" if os.name == "nt" else "/" + sem_input_map = { # class type : input to replace + "CheckpointLoaderSimple" : "ckpt_name", + "CheckpointLoader" : "ckpt_name", + "LoraLoader" : "lora_name", + "VAELoader" : "vae_name", + } + if sep_remote != sep_local: + for i in prompt.keys(): + if prompt[i]["class_type"] in sem_input_map.keys(): + key = sem_input_map[prompt[i]["class_type"]] + prompt[i]["inputs"][key] = prompt[i]["inputs"][key].replace(sep_local, sep_remote) + + ### SEND REQUEST ### + data = { + "prompt": prompt, + "client_id": get_client_id(), + "extra_data": { + "job_id": job_id, + } + } + ar = requests.post( + f"{remote_url}/prompt", + data = json.dumps(data), + headers = {"Content-Type": "application/json"}, + timeout = 4, + ) + ar.raise_for_status() + return diff --git a/core/fetch.py b/core/fetch.py new file mode 100644 index 0000000..41f9cb1 --- /dev/null +++ b/core/fetch.py @@ -0,0 +1,97 @@ +import time +import json +import torch +import requests +import numpy as np +from PIL import Image + +POLLING = 0.5 + +def get_job_output(inputs, outputs): + output_id = list(outputs.keys())[-1] # fallback to last + for i,d in inputs.items(): + if d.get("final_output") and i in outputs.keys(): + output_id = i + break + return outputs[output_id].get("images", []) + +def wait_for_job(remote_url, job_id): + fail = 0 + while fail <= 3: + r = requests.get(f"{remote_url}/history", timeout=4) + try: + r.raise_for_status() + except Exception as e: + print("NetDist caught error while fetching output image:\n", e) + fail += 1 + continue + data = r.json() + if not data: + time.sleep(POLLING) + continue + for i,d in data.items(): + if d["prompt"][3].get("job_id") == job_id: + # this needs to be less jank + if len(d["outputs"].keys()) > 0: + return get_job_output(d["prompt"][2], d["outputs"]) + else: + return [] + # todo: check if it's actually in the queue to avoid waiting forever + time.sleep(POLLING) + raise OSError("Failed to fetch image from remote client!") + +def fetch_from_remote(remote_url, job_id): + def img_to_torch(img): + image = img.convert("RGB") + image = np.array(image).astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(image)[None,] + image_tensor.metadata = img.info # Store metadata in tensor attribute + return image_tensor + + if not remote_url or not job_id: + return None + + images = [] + for i in wait_for_job(remote_url, job_id): + img_url = f"{remote_url}/view?filename={i['filename']}&subfolder={i['subfolder']}&type={i['type']}" + + ir = requests.get(img_url, stream=True, timeout=16) + ir.raise_for_status() + img = Image.open(ir.raw) + images.append(img_to_torch(img)) + + if len(images) == 0: + return None + + out = images[0] + for i in images[1:]: + out = torch.cat((out, i)) + return out + +#with extras returns both the output and the metadata from the images generated remotely +def fetch_from_remote_with_extras(remote_url, job_id): + def img_to_torch(img): + image = img.convert("RGB") + image = np.array(image).astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(image)[None,] + return image_tensor + + if not remote_url or not job_id: + return None + + images = [] + for i in wait_for_job(remote_url, job_id): + img_url = f"{remote_url}/view?filename={i['filename']}&subfolder={i['subfolder']}&type={i['type']}" + + ir = requests.get(img_url, stream=True, timeout=16) + ir.raise_for_status() + img = Image.open(ir.raw) + images.append(img_to_torch(img)) + + if len(images) == 0: + return None + + out = images[0] + for i in images[1:]: + out = torch.cat((out, i)) + return out, img.info \ No newline at end of file diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..07e9737 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,23 @@ +import time +import random + +# set global ID once for entire session +try: GID +except NameError: + GID = ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) + print(f"NetDist: Set session ID to '{GID}'") + +def get_client_id(): + global GID + return(f"netdist-{GID}") + +def get_new_job_id(): + job_id = f"{get_client_id()}-{int(time.time()*1000)}" + time.sleep(0.1) # prevent ID mismatch, no matter how unlikely + return job_id + +def clean_url(raw, multi=False): + raw = raw.strip() + raw = raw.replace(' ', ',').replace('\n', ',').replace('\t', ',') + urls = [x.rstrip('/') for x in raw.split(',') if x.strip()] + return urls if multi else urls[0] diff --git a/fonts/Roboto-Regular.ttf b/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3033308a695ab4efa60441c23f2c18aaa94c568f GIT binary patch literal 129584 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 TOP&AVyW^ewt}wmSK*#xSw!BZl literal 0 HcmV?d00001 diff --git a/mass-process/job.example.yaml b/mass-process/job.example.yaml new file mode 100644 index 0000000..68f8962 --- /dev/null +++ b/mass-process/job.example.yaml @@ -0,0 +1,28 @@ +workflow: "job.example.png" # Your actual workflow to distribute. +job_start: 1 # Start of job_num, will be incremented +job_end: 100 # until it reaches job_end. + +workers: # List of workers the server will connect to. + "RTX3080@LOC": # Client nickname. Can be anything. + url: "http://127.0.0.1:8188/" # ComfyUI URL (will be used for API). + system: "windows" # 'windows' or 'posix'. + "P40_0@NET": + url: "http://192.168.4.6:8188/" + system: "posix" + "P40_1@NET": + url: "http://192.168.4.6:8288/" + system: "posix" + +# Replace specific strings in the workflow inputs. +# Source string should be the one present in your saved workflow. +# Destination string will be the one the clients see. +replacement: + - + src: "http://127.0.0.1:8080/image/openpose/0000.png" + dst: "http://127.0.0.1:8080/image/openpose/{job_num:04}.png" + - + src: "http://192.168.4.4:8080/0000.png" + dst: "http://192.168.4.4:8080/{job_num:04}.png" + - + src: "http://example.lan/upload/output/0000.png" + dst: "http://example.lan/upload/output/{job_num:04}.png" diff --git a/mass-process/readme.md b/mass-process/readme.md new file mode 100644 index 0000000..ccfd59b --- /dev/null +++ b/mass-process/readme.md @@ -0,0 +1,9 @@ +This is the script I used when I had to mass-process some controlnet inputs for an animation. It was requested that I publish this in [issue#2](https://github.com/city96/ComfyUI_NetDist/issues/2#issuecomment-1696342450) + +Note that you'll have to make all images accessible to the clients, otherwise they fail. I was using the load from URL nodes for this. For testing, I simply used the built-in python web server with `python -m http.server 8080`, but you can use your own web server to host them. + +I don't currently have seed randomization or proper output handling. For the later, just disable all the save/preview image nodes other than the one you'll be using as the final output. + +Here's a sample workflow: + +![job example](https://github.com/city96/ComfyUI_NetDist/assets/125218114/138ec97b-61a6-4631-a280-06b5c0e3c43d) diff --git a/mass-process/server.py b/mass-process/server.py new file mode 100644 index 0000000..33e38f0 --- /dev/null +++ b/mass-process/server.py @@ -0,0 +1,159 @@ +import os +import time +import yaml +import json +import requests +import argparse +from PIL import Image +from tqdm import tqdm +from queue import Queue +from copy import deepcopy +from threading import Thread + +class JobShard: + def __init__(self, workflow, job_num): + self.workflow = workflow # raw workflow + self.job_num = job_num # numerical ID of job + self.prompt = None # created when assigned to worker + self.job_id = None # ^ + + def format_workflow(self, rep, system, job_num): + w = deepcopy(self.workflow) + for i in w.keys(): + # Fix path mismatch + ct = w[i]["class_type"] + pr = ("\\","/") if system == "posix" else ("/","\\") + if ct == "LoraLoader": + w[i]["inputs"]["lora_name"] = w[i]["inputs"]["lora_name"].replace(*pr) + elif ct == "VAELoader": + w[i]["inputs"]["vae_name"] = w[i]["inputs"]["vae_name"].replace(*pr) + elif ct in ["CheckpointLoader","CheckpointLoaderSimple"]: + w[i]["inputs"]["ckpt_name"] = w[i]["inputs"]["ckpt_name"].replace(*pr) + # replace strings + for k in w[i].get("inputs",{}).keys(): + src = w[i]["inputs"][k] + dst = [x["dst"] for x in rep if x["src"] == src] + if dst: + w[i]["inputs"][k] = dst[0].format(job_num=job_num) + self.prompt = w + + def assign(self, worker): + self.format_workflow(worker.conf["replacement"], worker.system, self.job_num) + self.job_id = f"{worker.name}-{self.job_num}@{int(time.time())}" + +class Worker: + def __init__(self, name, system, url, conf, jobs, prog): + self.name = name + self.url = url.rstrip("/") if url.endswith("/") else url + self.system = system.lower().strip() + self.conf = conf # global config + self.jobs = jobs # queue of all jobs + self.prog = prog # progress bar + self.job = None + + def is_busy(self): + busy = True if self.job else False + return busy + + def run(self): + while not self.jobs.empty(): + self.job = self.jobs.get() + self.job.assign(self) + self.start_job() + self.fetch_job() + self.job = None + self.jobs.task_done() + self.prog.update() + + def start_job(self): + url = f"{self.url}/prompt" + data = { + "prompt": self.job.prompt, + "client_id": "netdist-mass", + "extra_data": { + "job_id": self.job.job_id, + } + } + r = requests.post(url, json=data) + r.raise_for_status() + + def wait_for_job(self): + url = self.url + "/history" + image_data = None + while not image_data: + r = requests.get(url) + r.raise_for_status() + data = r.json() + if not data: + time.sleep(0.5) + continue + for i,d in data.items(): + if d["prompt"][3].get("job_id") == self.job.job_id: + image_data = d["outputs"][list(d["outputs"].keys())[-1]].get("images") + break + time.sleep(0.5) + return image_data + + def fetch_job(self): + images = [] + for i in self.wait_for_job(): + img_url = f"{self.url}/view?filename={i['filename']}&subfolder={i['subfolder']}&type={i['type']}" + ir = requests.get(img_url, stream=True) + ir.raise_for_status() + images.append(Image.open(ir.raw)) + + if len(images) == 0: + print(f"{self.name}@{self.url} job failed") + elif len(images) == 1: + images[0].save(f"output/{self.job.job_num}.png") + else: + for i in range(len(images)): + images[i].save(f"output/{self.job.job_num}.{i}.png") + +def get_workflow(path): + if path.endswith(".png"): + img = Image.open(path) + data = json.loads( + img.text.get("prompt") + ) + else: + print("invalid input workflow") + exit(1) + return data + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('--conf', required=True, help="Config file describing job.") + args = parser.parse_args() + + with open(args.conf) as f: + conf = yaml.safe_load(f.read()) + + if not os.path.isdir("output"): + os.mkdir("output") + + # creqte queue with jobs + jobs = Queue() + wf = get_workflow(conf["workflow"]) + for job_num in range(conf["job_start"],conf["job_end"]): + jobs.put( + JobShard(wf, job_num) + ) + prog = tqdm(total=jobs.qsize()) + + # initialize workers + workers = [] + for name, k in conf["workers"].items(): + workers.append(Worker( + name=name, + system=k["system"], + url=k["url"], + jobs=jobs, + prog=prog, + conf=conf) + ) + + + # execute all + [Thread(target=w.run, daemon=True).start() for w in workers] + jobs.join() diff --git a/nodes/advanced.py b/nodes/advanced.py new file mode 100644 index 0000000..d970512 --- /dev/null +++ b/nodes/advanced.py @@ -0,0 +1,371 @@ +from ..core.utils import clean_url, get_client_id, get_new_job_id +from ..core.dispatch import dispatch_to_remote, clear_remote_queue + +import copy + +class RemoteApplyValues: + """Apply values to remote nodes""" + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "nodeid": ("STRING", {"default": ""}), + "param": ("STRING", {"default": ""}), + "value": ("STRING", {"default": ""}), + "type": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + } + } + + RETURN_TYPES = ("REMOTEAPPLY",) + RETURN_NAMES = ("remote_apply",) + FUNCTION = "apply_values" + CATEGORY = "remote/advanced" + TITLE = "Apply Values to Remote Nodes" + + def apply_values(self, nodeid, param, value, type): + return ((nodeid, param, value, type),) + +class RemoteApplyValuesMulti: + """Apply values to remote nodes""" + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "nodeid": ("STRING", {"default": ""}), + "param": ("STRING", {"default": ""}), + "value": ("STRING", {"default": ""}), + "type": ("STRING", {"default": "STRING"}), + } + } + + RETURN_TYPES = ("REMOTEAPPLY",) + RETURN_NAMES = ("remote_apply",) + FUNCTION = "apply_values" + CATEGORY = "remote/advanced" + TITLE = "Apply Values to Remote Nodes" + + def apply_values(self, nodeid, param, value, type): + nodeids = [id.strip() for id in nodeid.split(",")] + params = [p.strip() for p in param.split(",")] + values = [v.strip() for v in value.split(",")] + types = type.split(",") # Split the types string into a list + + # If only one type is provided, apply it to all values + if len(types) == 1: + types = types * len(nodeids) + else: + types = [t.strip() for t in types] # Strip whitespace from each type + + if len(nodeids) != len(params) or len(nodeids) != len(values) or len(nodeids) != len(types): + raise ValueError("Number of nodeids, params, values, and types must be equal") + + remote_values = tuple(zip(nodeids, params, values, types)) + return (remote_values,) + +class RemoteChainStartNux: + """Merge required attributes into one [REMCHAIN]""" + def __init__(self): + pass + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "workflow": ("JSON",), + "trigger": (["on_change", "always"],), + "batch": ("INT", {"default": 1, "min": 1, "max": 8}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + }, + "optional": { + "remoteapply1": ("REMOTEAPPLY",), + "remoteapply2": ("REMOTEAPPLY",), + "remoteapply3": ("REMOTEAPPLY",), + "remoteapply4": ("REMOTEAPPLY",), + "remoteapply5": ("REMOTEAPPLY",), + "remoteapply6": ("REMOTEAPPLY",), + "remoteapply7": ("REMOTEAPPLY",), + "remoteapply8": ("REMOTEAPPLY",), + "remoteapply9": ("REMOTEAPPLY",), + "remoteapply10": ("REMOTEAPPLY",), + } + } + + RETURN_TYPES = ("REMCHAIN",) + RETURN_NAMES = ("remote_chain",) + FUNCTION = "chain_start" + CATEGORY = "remote/advanced" + TITLE = "Queue on remote (start of chain)" + + def chain_start(self, workflow, trigger, batch, seed, + remoteapply1=None, remoteapply2=None, remoteapply3=None, remoteapply4=None, + remoteapply5=None, remoteapply6=None, remoteapply7=None, remoteapply8=None, + remoteapply9=None, remoteapply10=None): + + # Make a deep copy of the workflow to avoid retaining state between calls + workflow = copy.deepcopy(workflow) + + remote_params = {} + for remoteapply in [remoteapply1, remoteapply2, remoteapply3, remoteapply4, + remoteapply5, remoteapply6, remoteapply7, remoteapply8, + remoteapply9, remoteapply10]: + print("**"+str(remoteapply6)+str(type(remoteapply6))+"**") + if remoteapply: + if isinstance(remoteapply[0], tuple): # Check if it's a tuple of tuples (multi) + for nodeid, param, value, value_type in remoteapply: + if param and value: + + remote_params[(nodeid, param)] = self.parse_value(value, value_type) + else: # Single tuple + nodeid, param, value, value_type = remoteapply + if param and value: + remote_params[(nodeid, param)] = self.parse_value(value, value_type) + + remote_params[("", "seed")] = self.parse_value(seed, "INT") # Add seed to remote_params + + # Apply remote parameters to the prompt + for (nodeid, param), value in remote_params.items(): + truncated_value = str(value)[:30] + '...' if len(str(value)) > 30 else str(value) # Truncate value for display + print(f"Applying param: {param}, value: {truncated_value}, nodeid: {nodeid}") # Debug statement + if nodeid: + if nodeid in workflow: + if param in workflow[nodeid].get("inputs", {}): + workflow[nodeid]["inputs"][param] = value + print(f"Updated node {nodeid} param {param} with value {truncated_value}") # Debug statement + else: + print(f"Param {param} not found in node {nodeid} inputs") # Debug statement + else: + print(f"Node {nodeid} not found in workflow") # Debug statement + else: + for node_key, node_data in workflow.items(): + if param in node_data.get("inputs", {}): + workflow[node_key]["inputs"][param] = value + print(f"Updated node {node_key} param {param} with value {truncated_value}") # Debug statement + break + else: + print(f"Param {param} not found in node {node_key} inputs") # Debug statement + + remote_chain = { + "seed": seed, + "batch": batch, + "prompt": workflow, + "seed_offset": batch, + "job_id": get_new_job_id(), + } + return(remote_chain,) + + @classmethod + def IS_CHANGED(self, workflow, trigger, batch, seed, prompt): + uuid = f"W:{workflow},B:{batch},S:{seed}" + return uuid if trigger == "on_change" else str(time.time()) + + def parse_value(self, value, value_type): + """Parse the value string to the specified type.""" + if value_type == "INT": + return int(value) + elif value_type == "FLOAT": + return float(value) + elif value_type == "BOOL": + return value.lower() == "true" + else: # STRING or any other type + return value.replace("\\", "\\\\") + +class RemoteChainStart: + """Merge required attributes into one [REMCHAIN]""" + def __init__(self): + pass + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "workflow": ("JSON",), + "trigger": (["on_change", "always"],), + "batch": ("INT", {"default": 1, "min": 1, "max": 8}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + }, + "optional": { + "remote_nodeid1": ("STRING", {"default": ""}), + "remote_param1": ("STRING", {"default": ""}), + "remote_value1": ("STRING", {"default": ""}), + "remote_type1": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodeid2": ("STRING", {"default": ""}), + "remote_param2": ("STRING", {"default": ""}), + "remote_value2": ("STRING", {"default": ""}), + "remote_type2": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodeid3": ("STRING", {"default": ""}), + "remote_param3": ("STRING", {"default": ""}), + "remote_value3": ("STRING", {"default": ""}), + "remote_type3": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodeid4": ("STRING", {"default": ""}), + "remote_param4": ("STRING", {"default": ""}), + "remote_value4": ("STRING", {"default": ""}), + "remote_type4": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + } + } + + RETURN_TYPES = ("REMCHAIN",) + RETURN_NAMES = ("remote_chain",) + FUNCTION = "chain_start" + CATEGORY = "remote/advanced" + TITLE = "Queue on remote (start of chain)" + + def chain_start(self, workflow, trigger, batch, seed, + remote_nodeid1="", remote_param1="", remote_value1="", remote_type1="STRING", + remote_nodeid2="", remote_param2="", remote_value2="", remote_type2="STRING", + remote_nodeid3="", remote_param3="", remote_value3="", remote_type3="STRING", + remote_nodeid4="", remote_param4="", remote_value4="", remote_type4="STRING"): + + # Make a deep copy of the workflow to avoid retaining state between calls + workflow = copy.deepcopy(workflow) + + remote_params = {} + for nodeid, param, value, value_type in [ + (remote_nodeid1, remote_param1, remote_value1, remote_type1), + (remote_nodeid2, remote_param2, remote_value2, remote_type2), + (remote_nodeid3, remote_param3, remote_value3, remote_type3), + (remote_nodeid4, remote_param4, remote_value4, remote_type4), + ("", "seed", seed, "INT") + ]: + if param and value: + remote_params[(nodeid, param)] = self.parse_value(value, value_type) + + # Apply remote parameters to the prompt + for (nodeid, param), value in remote_params.items(): + truncated_value = str(value)[:30] + '...' if len(str(value)) > 30 else str(value) # Truncate value for display + print(f"Applying param: {param}, value: {truncated_value}, nodeid: {nodeid}") # Debug statement + if nodeid: + if nodeid in workflow: + if param in workflow[nodeid].get("inputs", {}): + workflow[nodeid]["inputs"][param] = value + print(f"Updated node {nodeid} param {param} with value {truncated_value}") # Debug statement + else: + print(f"Param {param} not found in node {nodeid} inputs") # Debug statement + else: + print(f"Node {nodeid} not found in workflow") # Debug statement + else: + for node_key, node_data in workflow.items(): + if param in node_data.get("inputs", {}): + workflow[node_key]["inputs"][param] = value + print(f"Updated node {node_key} param {param} with value {truncated_value}") # Debug statement + break + else: + print(f"Param {param} not found in node {node_key} inputs") # Debug statement + + remote_chain = { + "seed": seed, + "batch": batch, + "prompt": workflow, + "seed_offset": batch, + "job_id": get_new_job_id(), + } + return(remote_chain,) + + @classmethod + def IS_CHANGED(self, workflow, trigger, batch, seed, prompt): + uuid = f"W:{workflow},B:{batch},S:{seed}" + return uuid if trigger == "on_change" else str(time.time()) + + def parse_value(self, value, value_type): + """Parse the value string to the specified type.""" + if value_type == "INT": + return int(value) + elif value_type == "FLOAT": + return float(value) + elif value_type == "BOOL": + return value.lower() == "true" + else: # STRING or any other type + return value.replace("\\", "\\\\") + +class RemoteChainEnd: + """Split [REMCHAIN] into local seed/batch""" + def __init__(self): + pass + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "remote_chain": ("REMCHAIN",) + } + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("seed", "batch") + FUNCTION = "chain_end" + CATEGORY = "remote/advanced" + TITLE = "Queue on remote (end of chain)" + + def chain_end(self, remote_chain): + seed = remote_chain["seed"] + batch = remote_chain["batch"] + return(seed,batch) + +class RemoteQueueWorker: + """Start job on remote worker""" + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "remote_chain": ("REMCHAIN",), + "remote_url": ("STRING", { + "multiline": False, + "default": "http://127.0.0.1:8288/", + }), + "batch_override": ("INT", {"default": 0, "min": 0, "max": 8}), + "enabled": (["true", "false", "remote"],{"default": "true"}), + "outputs": (["final_image", "any"],{"default":"final_image"}), + }, + } + + RETURN_TYPES = ("REMCHAIN", "REMINFO") + RETURN_NAMES = ("remote_chain", "remote_info") + FUNCTION = "queue" + CATEGORY = "remote/advanced" + TITLE = "Queue on remote (worker)" + + def queue(self, remote_chain, remote_url, batch_override, enabled, outputs, + ): + current_offset = remote_chain["seed_offset"] + remote_chain["seed_offset"] += 1 if batch_override == 0 else batch_override + if enabled == "false": + return (remote_chain, {}) + if enabled == "remote": + # apply offset from previous nodes in chain + remote_chain["seed"] += current_offset + if batch_override > 0: + remote_chain["batch"] = batch_override + return (remote_chain, {}) + + remote_url = clean_url(remote_url) + clear_remote_queue(remote_url) + + # Prepare remote parameters + remote_params = {} + + dispatch_to_remote( + remote_url, + remote_chain["prompt"], + remote_chain["job_id"], + remote_params, + outputs, + ) + remote_info = { + "remote_url" : remote_url, + "job_id" : remote_chain["job_id"], + } + return (remote_chain, remote_info) + +NODE_CLASS_MAPPINGS = { + "RemoteApplyValues(Nux)": RemoteApplyValues, + "RemoteChainStart(Nux)": RemoteChainStartNux, + "RemoteChainStart" : RemoteChainStart, + "RemoteQueueWorker" : RemoteQueueWorker, + "RemoteChainEnd" : RemoteChainEnd, + "RemoteApplyValuesMulti(Nux)": RemoteApplyValuesMulti, +} diff --git a/nodes/images.py b/nodes/images.py new file mode 100644 index 0000000..10dc95a --- /dev/null +++ b/nodes/images.py @@ -0,0 +1,127 @@ +import os +import json +import torch +import requests +import numpy as np +from PIL import Image +from PIL.PngImagePlugin import PngInfo +from base64 import b64encode +from io import BytesIO + +class LoadImageUrl: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "url": ("STRING", { "multiline": False, }) + } + } + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "load_image_url" + CATEGORY = "remote/image" + TITLE = "Load Image (URL)" + + def load_image_url(self, url): + with requests.get(url, stream=True) as r: + r.raise_for_status() + i = Image.open(r.raw) + 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) + +class SaveImageUrl: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE", ), + "url": ("STRING", { "multiline": False, }), + "filename_prefix": ("STRING", {"default": "ComfyUI"}), + "data_format": (["HTML_image", "Raw_data"],) + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "save_images" + CATEGORY = "remote/image" + TITLE = "Save Image (URL)" + + def save_images(self, images, url, data_format, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + filename = os.path.basename(os.path.normpath(filename_prefix)) + + counter = 1 + data = {} + for image in images: + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + meta = PngInfo() + if prompt is not None: + meta.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + meta.add_text(x, json.dumps(extra_pnginfo[x])) + + file = f"{filename}_{counter:05}.png" + + buffer = BytesIO() + img.save(buffer, "png", pnginfo=meta, compress_level=4) + buffer.seek(0) + encoded = b64encode(buffer.read()).decode('utf-8') + data[file] = f"data:image/png;base64,{encoded}" if data_format == "HTML_image" else encoded + counter += 1 + + with requests.post(url, json=data) as r: + r.raise_for_status() + return () + +class CombineImageBatch: + """ + This isn't needed anymore but I used it in too many places so I'm keeping it... + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images_a": ("IMAGE",), + "images_b": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("images",) + FUNCTION = "combine_images" + CATEGORY = "remote/image" + TITLE = "Combine images" + + def combine_images(self,images_a,images_b): + try: + out = torch.cat((images_a,images_b), 0) + except RuntimeError: + print(f"Imagine size mismatch! {images_a.size()}, {images_b.size()}") + out = images_a + return (out,) + + +NODE_CLASS_MAPPINGS = { + "LoadImageUrl" : LoadImageUrl, + "SaveImageUrl" : SaveImageUrl, + "CombineImageBatch" : CombineImageBatch, +} diff --git a/nodes/latents.py b/nodes/latents.py new file mode 100644 index 0000000..33b356d --- /dev/null +++ b/nodes/latents.py @@ -0,0 +1,629 @@ +import os +import torch +import requests +import safetensors.torch +import numpy as np +import io +from io import BytesIO +import json +from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance +from PIL.PngImagePlugin import PngInfo +import folder_paths +import base64 + + + +class LoadLatentNumpy: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + exts = [".latent", ".safetensors", ".npy", ".npz"] + 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))] + files = [f for f in files if any([f.endswith(x) for x in exts])] + return { + "required": { + "latent": [sorted(files), ] + }, + } + + RETURN_TYPES = ("LATENT",) + FUNCTION = "load" + CATEGORY = "remote/latent" + TITLE = "Load Latent (Numpy)" + + def load_comfy(self, file): + # From default node - renamed safetensors file + if type(file) == str: + data = safetensors.torch.load_file(file) + else: + data = safetensors.torch.load(file) + + latent = data["latent_tensor"].to(torch.float32) + if "latent_format_version_0" not in data: + latent *= 1.0 / 0.18215 # XL? + return latent + + def load_numpy(self, file): + # plain npy file - saved as-is + return torch.from_numpy(np.load(file)) + + def load_koyha(self, file): + # generated by sd_scripts - npz + if "latents" in data.keys(): + latent = data["latents"] + else: + latent = [x for x in data.items() if x.shape > 3][0] + return torch.from_numpy(latent) + + def load(self, latent): + path = folder_paths.get_annotated_filepath(latent) + name, ext = os.path.splitext(latent) + + if ext in [".latent", ".safetensors"]: + latent = self.load_comfy(path) + elif ext == ".npy": + latent = self.load_numpy(path) + elif ext == ".npz": + latent = self.load_koyha(path) + else: + try: + latent = self.load_numpy(path) + except: + raise ValueError(f"Unknown latent extension '{ext}'") + + if len(latent.shape) == 3: + latent = latent.unsqueeze(0) + print("asdasd", latent.shape) + + return ({"samples": latent.to(torch.float32)},) + + @classmethod + def IS_CHANGED(s, latent): + image_path = folder_paths.get_annotated_filepath(latent) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, latent): + if not folder_paths.exists_annotated_filepath(latent): + return f"Invalid latent file '{latent}'" + return True + +class LoadLatentUrl(LoadLatentNumpy): + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "url": ("STRING", { "multiline": False, }) + } + } + + RETURN_TYPES = ("LATENT",) + TITLE = "Load Latent (URL)" + + def load(self, url): + buffer = BytesIO() + with requests.get(url, stream=True, timeout=16) as r: + r.raise_for_status() + buffer.write(r.content) + buffer.seek(0) + + if ".latent" in url or ".safetensors" in url: + latent = self.load_comfy(buffer) + elif ".npy" in url: + latent = self.load_numpy(buffer) + elif ".npz" in url: + latent = self.load_koyha(buffer) + else: + try: + latent = self.load_comfy(buffer) + except: + raise ValueError(f"Unknown latent extension '{url}'") + + if len(latent.shape) == 3: + latent = latent.unsqueeze(0) + + del buffer + return ({"samples": latent.to(torch.float32)},) + + @classmethod + def IS_CHANGED(s, url): + return str(url) + + @classmethod + def VALIDATE_INPUTS(s, url): + return True + +class SaveLatentNumpy: + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "samples": ("LATENT",), + "filename_prefix": ("STRING", {"default": "latents/ComfyUI"}) + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("filename",) + OUTPUT_NODE = True + FUNCTION = "save" + CATEGORY = "remote/latent" + TITLE = "Save Latent (Numpy)" + + def save(self, samples, filename_prefix="ComfyUI"): + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir) + fname = f"{filename}_{counter:05}_.npy" + path = os.path.join(full_output_folder, fname) + np.save(path, samples["samples"].numpy()) + return (fname,) + + +class LatentToBase64Nux: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "samples": ("LATENT",), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("base64_latent",) + FUNCTION = "convert" + CATEGORY = "remote/latent" + TITLE = "Latent to Base64" + + def convert(self, samples): + # Convert the latent samples to a numpy array + latent_array = samples["samples"].numpy() + + # Save the numpy array to a bytes buffer + buffer = io.BytesIO() + np.save(buffer, latent_array) + buffer.seek(0) + + # Encode the bytes to base64 + base64_latent = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return (base64_latent,) + + +class LoadLatentFromBase64Nux: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "base64_latent": ("STRING", {"multiline": True, "default": "", "forceInput": True}, ), + }, + } + + RETURN_TYPES = ("LATENT",) + FUNCTION = "load" + CATEGORY = "remote/latent" + TITLE = "Load Latent from Base64" + + def load(self, base64_latent=""): + try: + # Decode the base64 string + decoded_data = base64.b64decode(base64_latent) + + # Load the numpy array from the decoded data + buffer = io.BytesIO(decoded_data) + latent_array = np.load(buffer) + + # Convert numpy array to torch tensor + latent_tensor = torch.from_numpy(latent_array).to(torch.float32) + + # Ensure the tensor has the correct shape (add batch dimension if necessary) + if len(latent_tensor.shape) == 3: + latent_tensor = latent_tensor.unsqueeze(0) + + print("Loaded latent shape:", latent_tensor.shape) + + return ({"samples": latent_tensor},) + + except Exception as e: + raise ValueError(f"Failed to load latent from base64: {str(e)}") + + @classmethod + def IS_CHANGED(s, base64_latent): + # Since the input is a string, we can use its hash as a change indicator + return hash(base64_latent) + + @classmethod + def VALIDATE_INPUTS(s, base64_latent): + if not base64_latent: + return "Base64 latent string is empty" + try: + decoded_data = base64.b64decode(base64_latent) + buffer = io.BytesIO(decoded_data) + np.load(buffer) + except: + return "Invalid base64 latent string" + return True + + + +class ConditioningToBase64: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "conditioning": ("CONDITIONING", {"tooltip": "The conditioning to be encoded as base64."}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("base64_conditioning",) + FUNCTION = "convert" + CATEGORY = "conditioning" + TITLE = "Conditioning2Base64" + + def convert(self, conditioning): + # Extract the conditioning data + cond_data, cond_meta = conditioning[0] + + # Convert tensors to numpy arrays + cond_data_np = cond_data.cpu().numpy() + cond_meta_serializable = {k: v.cpu().numpy() if isinstance(v, torch.Tensor) else v for k, v in cond_meta.items()} + + # Combine data and metadata into a single dictionary + combined_data = { + "cond_data": cond_data_np, + "cond_meta": cond_meta_serializable + } + + # Save the combined data to a bytes buffer + buffer = io.BytesIO() + np.savez_compressed(buffer, **combined_data) + buffer.seek(0) + + # Encode the bytes to base64 + base64_conditioning = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return (base64_conditioning,) + +class ConditioningFromBase64: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "base64_conditioning": ("STRING", {"multiline": True, "forceInput": True}), + } + } + + RETURN_TYPES = ("CONDITIONING",) + FUNCTION = "convert" + CATEGORY = "conditioning" + TITLE = "Remote Conditioning" + + def convert(self, base64_conditioning): + try: + # Decode the base64 string + decoded_data = base64.b64decode(base64_conditioning) + + # Load the numpy arrays from the decoded data + buffer = io.BytesIO(decoded_data) + loaded_data = np.load(buffer, allow_pickle=True) + + # Extract the conditioning data and metadata + cond_data_np = loaded_data['cond_data'] + cond_meta_serializable = loaded_data['cond_meta'].item() + + # Convert numpy arrays back to tensors + cond_data = torch.from_numpy(cond_data_np) + cond_meta = {k: torch.from_numpy(v) if isinstance(v, np.ndarray) else v for k, v in cond_meta_serializable.items()} + + # Reconstruct the conditioning object + conditioning = [[cond_data, cond_meta]] + + return (conditioning,) + + except Exception as e: + raise ValueError(f"Failed to load conditioning from base64: {str(e)}") + +def align_text(align, img_height, text_height, text_pos_y, margins): + if align == "center": + text_plot_y = img_height / 2 - text_height / 2 + text_pos_y + elif align == "top": + text_plot_y = text_pos_y + margins + elif align == "bottom": + text_plot_y = img_height - text_height + text_pos_y - margins + return text_plot_y + + +def justify_text(justify, img_width, line_width, margins): + if justify == "left": + text_plot_x = 0 + margins + elif justify == "right": + text_plot_x = img_width - line_width - margins + elif justify == "center": + text_plot_x = img_width/2 - line_width/2 + return text_plot_x + + +def get_text_size(draw, text, font): + bbox = draw.textbbox((0, 0), text, font=font) + + # Calculate the text width and height + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + return text_width, text_height + +def draw_masked_text(text_mask, text, + font_name, font_size, + margins, line_spacing, + position_x, position_y, + align, justify, + rotation_angle, rotation_options): + + # Create the drawing context + draw = ImageDraw.Draw(text_mask) + + # Define font settings + font_folder = "fonts" + font_file = os.path.join(font_folder, font_name) + resolved_font_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), font_file) + font = ImageFont.truetype(str(resolved_font_path), size=font_size) + + # Split the input text into lines + text_lines = text.split('\n') + + # Calculate the size of the text plus padding for the tallest line + max_text_width = 0 + max_text_height = 0 + + for line in text_lines: + # Calculate the width and height of the current line + line_width, line_height = get_text_size(draw, line, font) + + line_height = line_height + line_spacing + max_text_width = max(max_text_width, line_width) + max_text_height = max(max_text_height, line_height) + + # Get the image width and height + image_width, image_height = text_mask.size + image_center_x = image_width / 2 + image_center_y = image_height / 2 + + text_pos_y = position_y + sum_text_plot_y = 0 + text_height = max_text_height * len(text_lines) + + for line in text_lines: + # Calculate the width of the current line + line_width, _ = get_text_size(draw, line, font) + + # Get the text x and y positions for each line + text_plot_x = position_x + justify_text(justify, image_width, line_width, margins) + text_plot_y = align_text(align, image_height, text_height, text_pos_y, margins) + + # Add the current line to the text mask + draw.text((text_plot_x, text_plot_y), line, fill=255, font=font) + + text_pos_y += max_text_height # Move down for the next line + sum_text_plot_y += text_plot_y # Sum the y positions + + # Calculate centers for rotation + text_center_x = text_plot_x + max_text_width / 2 + text_center_y = sum_text_plot_y / len(text_lines) + + if rotation_options == "text center": + rotated_text_mask = text_mask.rotate(rotation_angle, center=(text_center_x, text_center_y)) + elif rotation_options == "image center": + rotated_text_mask = text_mask.rotate(rotation_angle, center=(image_center_x, image_center_y)) + + return rotated_text_mask + + +class SaveImageWithBase64: + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + self.prefix_append = "" + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE", {"tooltip": "The images to save."}), + "filename_prefix": ("STRING", {"default": "ComfyUI",}) + }, + "optional": { + "workflowName": ("STRING", {"default": "",}), + "latent": ("LATENT",), + "positive_conditioning": ("CONDITIONING",), + "negative_conditioning": ("CONDITIONING",) + }, + "hidden": { + "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" + }, + } + + RETURN_TYPES = () + FUNCTION = "save_images" + OUTPUT_NODE = True + CATEGORY = "image" + TITLE = "save conds and latents" + + def save_images(self, images, filename_prefix="ComfyUI", workflowName="", latent=None, positive_conditioning=None, negative_conditioning=None, prompt=None, extra_pnginfo=None): + + def convertconditioning(conditioning): + cond_data, cond_meta = conditioning[0] + # Convert tensors to numpy arrays + cond_data_np = cond_data.cpu().numpy() + cond_meta_serializable = {k: v.cpu().numpy() if isinstance(v, torch.Tensor) else v for k, v in cond_meta.items()} + # Combine data and metadata into a single dictionary + combined_data = { + "cond_data": cond_data_np, + "cond_meta": cond_meta_serializable + } + # Save the combined data to a bytes buffer + buffer = io.BytesIO() + np.savez_compressed(buffer, **combined_data) + buffer.seek(0) + # Encode the bytes to base64 + base64_conditioning = base64.b64encode(buffer.getvalue()).decode('utf-8') + return base64_conditioning + + def convertlatent(samples): + # Convert the latent samples to a numpy array + latent_array = samples["samples"].numpy() + # Save the numpy array to a bytes buffer + buffer = io.BytesIO() + np.save(buffer, latent_array) + buffer.seek(0) + # Encode the bytes to base64 + base64_latent = base64.b64encode(buffer.getvalue()).decode('utf-8') + return base64_latent + + filename_prefix += self.prefix_append + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]) + results = list() + for (batch_number, image) in enumerate(images): + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + # Add text overlay if workflowName is not empty + if workflowName: + #credits to comfyroll for text overlay + # Convert tensor images + image_3d = images[0, :, :, :] + # Create PIL images for the text and background layers and text mask + back_image = tensor2pil(image_3d) + text_image = Image.new('RGB', back_image.size, (255, 255, 255)) + text_mask = Image.new('L', back_image.size) + + # Draw the text on the text mask + rotated_text_mask = draw_masked_text(text_mask, workflowName, "Roboto-Regular.ttf", 30, + 0, 0, + 0, 0, + 'center', 'center', + 0, 'text center') + + # Composite the text image onto the background image using the rotated text mask + img = Image.composite(text_image, back_image, rotated_text_mask) + + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + if latent is not None: + latent_base64 = convertlatent(latent) + metadata.add_text("latent_base64", latent_base64) + if positive_conditioning is not None: + p_conditioning_base64 = convertconditioning(positive_conditioning) + metadata.add_text("conditioning_base64", p_conditioning_base64) + if negative_conditioning is not None: + n_conditioning_base64 = convertconditioning(negative_conditioning) + metadata.add_text("conditioning_base64", n_conditioning_base64) + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.png" + img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type + }) + counter += 1 + return { "ui": { "images": results } } + + +class ExtractBase64FromImageUpload: + @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), {"image_upload": True})}, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("latent_base64", "conditioning_base64") + FUNCTION = "extract" + CATEGORY = "image" + TITLE = "Extracts base64 encoded latent and conditioning data from an image's metadata." + + def extract(self, image): + image_path = folder_paths.get_annotated_filepath(image) + img = Image.open(image_path) + + latent_base64 = None + conditioning_base64 = None + + if "latent_base64" in img.info: + latent_base64 = img.info["latent_base64"] + + if "conditioning_base64" in img.info: + conditioning_base64 = img.info["conditioning_base64"] + + return (latent_base64, conditioning_base64) + + @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() + +def tensor2pil(t_image: torch.Tensor) -> Image: + return Image.fromarray(np.clip(255.0 * t_image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + +class ExtractBase64FromImage: + @classmethod + def INPUT_TYPES(s): + return {"required": {"image": ("IMAGE",)}} + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("latent_base64", "conditioning_base64") + FUNCTION = "extract" + CATEGORY = "image" + TITLE = "Extracts base64 encoded latent and conditioning data from an image tensor's metadata." + + def extract(self, image): + # Access the tensor directly + tensor = image[0] + + latent_base64 = None + conditioning_base64 = None + + # Retrieve metadata from the tensor + if hasattr(tensor, 'metadata'): + metadata = tensor.metadata + print("Metadata keys:", list(metadata.keys())) # Print the metadata keys + latent_base64 = metadata.get("latent_base64", None) + conditioning_base64 = metadata.get("conditioning_base64", None) + + return (latent_base64, conditioning_base64) + +NODE_CLASS_MAPPINGS = { + "LoadLatentFromBase64(Nux)": LoadLatentFromBase64Nux, + "LatentToBase64(Nux)": LatentToBase64Nux, + "LoadLatentNumpy": LoadLatentNumpy, + "LoadLatentUrl": LoadLatentUrl, + "SaveLatentNumpy": SaveLatentNumpy, + "ConditioningToBase64(Nux)": ConditioningToBase64, # New class + "ConditioningFromBase64(Nux)": ConditioningFromBase64, + "SaveImageWithBase64(Nux)": SaveImageWithBase64, + "ExtractBase64FromImage(Nux)": ExtractBase64FromImage, + "ExtractBase64FromImageUpload(Nux)": ExtractBase64FromImageUpload +} \ No newline at end of file diff --git a/nodes/simple.py b/nodes/simple.py new file mode 100644 index 0000000..d383d3c --- /dev/null +++ b/nodes/simple.py @@ -0,0 +1,289 @@ +from ..core.fetch import fetch_from_remote, fetch_from_remote_with_extras +from ..core.utils import clean_url, get_client_id, get_new_job_id +from ..core.dispatch import dispatch_to_remote, clear_remote_queue + +class FetchRemote(): + """ + Try to retrieve the final output image from the remote client. + On the remote client, this is replaced with a preview image node. + I.e. remote_info can be none, but the node shouldn't exist at that point + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "final_image": ("IMAGE",), + "remote_info": ("REMINFO",), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "fetch" + CATEGORY = "remote" + TITLE = "Fetch from remote" + + def fetch(self, final_image, remote_info): + out = fetch_from_remote( + remote_url = remote_info.get("remote_url"), + job_id = remote_info.get("job_id"), + ) + if out is None: + out = final_image[:1] * 0.0 # black image + return (out,) + +#with extras returns, the image, remote latent and conditioning if there are any +class FetchRemoteWithExtras(): + """ + Try to retrieve the final output image from the remote client. + On the remote client, this is replaced with a preview image node. + I.e. remote_info can be none, but the node shouldn't exist at that point + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "final_image": ("IMAGE",), + "remote_info": ("REMINFO",), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("IMAGE", "latent_base64", "conditioning_base64") + FUNCTION = "fetch" + CATEGORY = "remote" + TITLE = "Fetch from remote" + + def fetch(self, final_image, remote_info): + out, metadata = fetch_from_remote_with_extras( + remote_url = remote_info.get("remote_url"), + job_id = remote_info.get("job_id"), + ) + if out is None: + out = final_image[:1] * 0.0 # black image + + latent = metadata.get("latent_base64", None) + conditioning = metadata.get("conditioning_base64", None) + + return (out, latent, conditioning) + +import ast + +#v2 of remote queue to prevent to fix ui mess +class RemoteQueueSimpleNux(): + """ + This is a "simplified" version with additional optional parameters for remote execution. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "remote_url": ("STRING", { + "multiline": False, + "default": "http://127.0.0.1:8288/", + }), + "batch_local": ("INT", {"default": 1, "min": 1, "max": 8}), + "batch_remote": ("INT", {"default": 1, "min": 1, "max": 8}), + "trigger": (["on_change", "always"],), + "enabled": (["true", "false", "remote"],{"default": "true"}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + }, + "optional": { + "remoteapply1": ("REMOTEAPPLY",), + "remoteapply2": ("REMOTEAPPLY",), + "remoteapply3": ("REMOTEAPPLY",), + "remoteapply4": ("REMOTEAPPLY",), + "remoteapply5": ("REMOTEAPPLY",), + "remoteapply6": ("REMOTEAPPLY",), + "remoteapply7": ("REMOTEAPPLY",), + "remoteapply8": ("REMOTEAPPLY",), + "remoteapply9": ("REMOTEAPPLY",), + "remoteapply10": ("REMOTEAPPLY",), + }, + "hidden": { + "prompt": "PROMPT", + }, + } + + RETURN_TYPES = ("INT", "INT", "REMINFO",) + RETURN_NAMES = ("seed", "batch", "remote_info",) + FUNCTION = "queue" + CATEGORY = "remote" + TITLE = "Queue on remote (single)" + + def parse_value(self, value, value_type): + """Parse the value string to the specified type.""" + if value_type == "INT": + return int(value) + elif value_type == "FLOAT": + return float(value) + elif value_type == "BOOL": + return value.lower() == "true" + else: # STRING or any other type + return value + + def queue(self, remote_url, batch_local, batch_remote, trigger, enabled, seed, prompt, + remoteapply1=None, remoteapply2=None, remoteapply3=None, remoteapply4=None, + remoteapply5=None, remoteapply6=None, remoteapply7=None, remoteapply8=None, + remoteapply9=None, remoteapply10=None): + if enabled == "false": + return (seed, batch_local, {}) + if enabled == "remote": + return (seed+batch_local, batch_remote, {}) + + job_id = get_new_job_id() + remote_url = clean_url(remote_url) + clear_remote_queue(remote_url) + + # Prepare remote parameters + remote_params = [] + for remoteapply in [remoteapply1, remoteapply2, remoteapply3, remoteapply4, + remoteapply5, remoteapply6, remoteapply7, remoteapply8, + remoteapply9, remoteapply10]: + if remoteapply: + nodetitle, param, value, value_type = remoteapply + if param and value: + remote_params.append((param, self.parse_value(value, value_type), nodetitle)) + + dispatch_to_remote(remote_url, prompt, job_id, remote_params) + remote_info = { + "remote_url" : remote_url, + "job_id" : job_id, + } + return (seed, batch_local, remote_info) + + @classmethod + def IS_CHANGED(self, remote_url, batch_local, batch_remote, trigger, enabled, seed, prompt, + remoteapply1=None, remoteapply2=None, remoteapply3=None, remoteapply4=None, + remoteapply5=None, remoteapply6=None, remoteapply7=None, remoteapply8=None, + remoteapply9=None, remoteapply10=None): + uuid = f"W:{remote_url},B1:{batch_local},B2:{batch_remote},S:{seed},E:{enabled}" + for i, remoteapply in enumerate([remoteapply1, remoteapply2, remoteapply3, remoteapply4, + remoteapply5, remoteapply6, remoteapply7, remoteapply8, + remoteapply9, remoteapply10], start=1): + if remoteapply: + param, value, value_type, nodetitle = remoteapply + uuid += f",RP{i}:{param}:{value}:{value_type}:{nodetitle}" + return uuid if trigger == "on_change" else str(time.time()) + +class RemoteQueueSimple(): + """ + This is a "simplified" version with additional optional parameters for remote execution. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "remote_url": ("STRING", { + "multiline": False, + "default": "http://127.0.0.1:8288/", + }), + "batch_local": ("INT", {"default": 1, "min": 1, "max": 8}), + "batch_remote": ("INT", {"default": 1, "min": 1, "max": 8}), + "trigger": (["on_change", "always"],), + "enabled": (["true", "false", "remote"],{"default": "true"}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + }, + #init idea for entering settings for the remote workflow. + "optional": { + "remote_param1": ("STRING", {"default": ""}), + "remote_value1": ("STRING", {"default": ""}), + "remote_type1": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodetitle1": ("STRING", {"default": ""}), # Added nodetitle + "remote_param2": ("STRING", {"default": ""}), + "remote_value2": ("STRING", {"default": ""}), + "remote_type2": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodetitle2": ("STRING", {"default": ""}), # Added nodetitle + "remote_param3": ("STRING", {"default": ""}), + "remote_value3": ("STRING", {"default": ""}), + "remote_type3": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodetitle3": ("STRING", {"default": ""}), # Added nodetitle + "remote_param4": ("STRING", {"default": ""}), + "remote_value4": ("STRING", {"default": ""}), + "remote_type4": (["STRING", "INT", "FLOAT", "BOOL"], {"default": "STRING"}), + "remote_nodetitle4": ("STRING", {"default": ""}), # Added nodetitle + }, + "hidden": { + "prompt": "PROMPT", + }, + } + + RETURN_TYPES = ("INT", "INT", "REMINFO",) + RETURN_NAMES = ("seed", "batch", "remote_info",) + FUNCTION = "queue" + CATEGORY = "remote" + TITLE = "Queue on remote (single)" + + def parse_value(self, value, value_type): + """Parse the value string to the specified type.""" + if value_type == "INT": + return int(value) + elif value_type == "FLOAT": + return float(value) + elif value_type == "BOOL": + return value.lower() == "true" + else: # STRING or any other type + return value + + def queue(self, remote_url, batch_local, batch_remote, trigger, enabled, seed, prompt, + remote_param1="", remote_value1="", remote_type1="STRING", remote_nodetitle1="", + remote_param2="", remote_value2="", remote_type2="STRING", remote_nodetitle2="", + remote_param3="", remote_value3="", remote_type3="STRING", remote_nodetitle3="", + remote_param4="", remote_value4="", remote_type4="STRING", remote_nodetitle4=""): + if enabled == "false": + return (seed, batch_local, {}) + if enabled == "remote": + return (seed+batch_local, batch_remote, {}) + + job_id = get_new_job_id() + remote_url = clean_url(remote_url) + clear_remote_queue(remote_url) + + # Prepare remote parameters + remote_params = [] + for param, value, value_type, nodetitle in [ + (remote_param1, remote_value1, remote_type1, remote_nodetitle1), + (remote_param2, remote_value2, remote_type2, remote_nodetitle2), + (remote_param3, remote_value3, remote_type3, remote_nodetitle3), + (remote_param4, remote_value4, remote_type4, remote_nodetitle4) + ]: + if param and value: + remote_params.append((param, self.parse_value(value, value_type), nodetitle)) + + dispatch_to_remote(remote_url, prompt, job_id, remote_params) + remote_info = { + "remote_url" : remote_url, + "job_id" : job_id, + } + return (seed, batch_local, remote_info) + + @classmethod + def IS_CHANGED(self, remote_url, batch_local, batch_remote, trigger, enabled, seed, prompt, + remote_param1="", remote_value1="", remote_type1="STRING", remote_nodetitle1="", + remote_param2="", remote_value2="", remote_type2="STRING", remote_nodetitle2="", + remote_param3="", remote_value3="", remote_type3="STRING", remote_nodetitle3="", + remote_param4="", remote_value4="", remote_type4="STRING", remote_nodetitle4=""): + uuid = f"W:{remote_url},B1:{batch_local},B2:{batch_remote},S:{seed},E:{enabled}" + uuid += f",RP1:{remote_param1}:{remote_value1}:{remote_type1}:{remote_nodetitle1}" + uuid += f",RP2:{remote_param2}:{remote_value2}:{remote_type2}:{remote_nodetitle2}" + uuid += f",RP3:{remote_param3}:{remote_value3}:{remote_type3}:{remote_nodetitle3}" + uuid += f",RP4:{remote_param4}:{remote_value4}:{remote_type4}:{remote_nodetitle4}" + return uuid if trigger == "on_change" else str(time.time()) + +NODE_CLASS_MAPPINGS = { + "RemoteQueueSimple(Nux)" : RemoteQueueSimpleNux, + "RemoteQueueSimple" : RemoteQueueSimple, + "FetchRemote" : FetchRemote, + "FetchRemoteWithExtras(Nux)": FetchRemoteWithExtras, +} diff --git a/nodes/workflows.py b/nodes/workflows.py new file mode 100644 index 0000000..3ccb302 --- /dev/null +++ b/nodes/workflows.py @@ -0,0 +1,211 @@ +import os +import json +import hashlib +import folder_paths + +class SaveDiskWorkflowJSON: + """Save workflow to disk""" + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "workflow": ("JSON", ), + "filename_prefix": ("STRING", {"default": "workflow/ComfyUI"}), + } + } + + RETURN_TYPES = () + FUNCTION = "save_workflow" + OUTPUT_NODE = True + CATEGORY = "remote/advanced" + TITLE = "Save workflow (disk)" + + def save_workflow(self, workflow, filename_prefix): + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir) + + json_path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.json") + with open(json_path, "w") as f: + f.write(json.dumps(workflow, indent=2)) + return {} + +class LoadDiskWorkflowJSON: + """Load workflow JSON from disk""" + def __init__(self): + pass + + @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)) and f.endswith(".json")] + return { + "required": { + "workflow": [sorted(files),], + } + } + + RETURN_TYPES = ("JSON",) + RETURN_NAMES = ("Workflow JSON",) + FUNCTION = "load_workflow" + CATEGORY = "remote/advanced" + TITLE = "Load workflow (disk)" + + def load_workflow(self, workflow): + json_path = folder_paths.get_annotated_filepath(workflow) + with open(json_path) as f: + data = json.loads(f.read()) + return (data,) + + @classmethod + def IS_CHANGED(s, workflow): + json_path = folder_paths.get_annotated_filepath(workflow) + m = hashlib.sha256() + with open(json_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, workflow): + if not folder_paths.exists_annotated_filepath(workflow): + return "Invalid JSON file: {}".format(workflow) + json_path = folder_paths.get_annotated_filepath(workflow) + with open(json_path) as f: + try: json.loads(f.read()) + except: + return "Failed to read JSON file: {}".format(workflow) + return True + +class LoadCurrentWorkflowJSON: + """Fetch the current workflow/prompt as an API compatible JSON""" + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": {}, + "hidden": { + "prompt": "PROMPT", + }, + } + + RETURN_TYPES = ("JSON",) + RETURN_NAMES = ("Workflow JSON",) + FUNCTION = "load_workflow" + CATEGORY = "remote/advanced" + TITLE = "Load workflow (current)" + + def load_workflow(self, prompt): + return (prompt,) + + @classmethod + def IS_CHANGED(s, prompt): + return hashlib.sha256(json.dumps(prompt)).digest().hex() + +import os +import json +import hashlib +import folder_paths +from PIL import Image +import piexif + + +class LoadWorkflowJSON: + """Load workflow JSON from disk or image upload""" + def __init__(self): + pass + + @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)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] + return { + "required": { + "image": (sorted(files), {"image_upload": True}), + } + } + + RETURN_TYPES = ("JSON",) + RETURN_NAMES = ("Workflow JSON",) + FUNCTION = "load_workflow" + CATEGORY = "remote/advanced" + TITLE = "Load workflow (image upload)" + + def load_workflow(self, image): + try: + image_path = folder_paths.get_annotated_filepath(image) + if not os.path.exists(image_path): + raise FileNotFoundError(f"Image file not found: {image_path}") + + with Image.open(image_path) as img: + # Try to get the EXIF data + exif_data = img.info.get("exif") + if not exif_data: + raise ValueError("No EXIF data found in the image") + + exif_dict = piexif.load(exif_data) + user_comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment) + if not user_comment: + raise ValueError("No UserComment found in EXIF data") + + metadata = json.loads(user_comment.decode("utf-8")) + workflow_json = metadata.get("prompt") + if not workflow_json: + raise ValueError("No 'prompt' field found in UserComment data") + + # Parse the JSON string into a Python object + workflow_data = json.loads(workflow_json) + return (workflow_data,) + + except Exception as e: + print(f"Error loading workflow from image: {str(e)}") + return ({},) # Return an empty JSON object in case of error + + @classmethod + def IS_CHANGED(s, image): + try: + image_path = folder_paths.get_annotated_filepath(image) + if not os.path.exists(image_path): + return "FILE_NOT_FOUND" + + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + except Exception as e: + print(f"Error in IS_CHANGED: {str(e)}") + return "ERROR" + + @classmethod + def VALIDATE_INPUTS(s, image): + try: + image_path = folder_paths.get_annotated_filepath(image) + if not os.path.exists(image_path): + return f"Image file not found: {image_path}" + + with Image.open(image_path) as img: + exif_data = img.info.get("exif") + if not exif_data: + return f"No EXIF data found in image: {image}" + + exif_dict = piexif.load(exif_data) + user_comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment) + if not user_comment: + return f"No UserComment found in EXIF data: {image}" + + metadata = json.loads(user_comment.decode("utf-8")) + if "prompt" not in metadata: + return f"No 'prompt' field found in UserComment data: {image}" + + return True + except Exception as e: + return f"Error validating image: {image}. Error: {str(e)}" + +NODE_CLASS_MAPPINGS = { + "SaveDiskWorkflowJSON": SaveDiskWorkflowJSON, + "LoadDiskWorkflowJSON": LoadDiskWorkflowJSON, + "LoadCurrentWorkflowJSON": LoadCurrentWorkflowJSON, + "LoadWorkflowJSON": LoadWorkflowJSON, +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..03ff241 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.28.2