├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── comfyui_to_python.py ├── comfyui_to_python_utils.py ├── images ├── SDXL-UI-Example.PNG ├── SDXL-UI-Example.jpg ├── comfyui_to_python_banner.png ├── dev_mode_options.PNG ├── dev_mode_options.jpg └── save_as_script.png ├── install.py ├── js └── save-as-script.js ├── pyproject.toml └── requirements.txt /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | jobs: 11 | publish-node: 12 | name: Publish Custom Node to registry 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Publish Custom Node 18 | uses: Comfy-Org/publish-node-action@main 19 | with: 20 | ## Add your own personal access token to your Github Repository secrets and reference it here. 21 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject some infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | db.sqlite3-journal 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 93 | __pypackages__/ 94 | 95 | # Celery stuff 96 | celerybeat-schedule 97 | celerybeat.pid 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | 129 | # pytype static type analyzer 130 | .pytype/ 131 | 132 | # Cython debug symbols 133 | cython_debug/ 134 | 135 | # JetBrains IDE 136 | .idea/ 137 | 138 | # VS Code settings 139 | .vscode/ 140 | 141 | # Data files 142 | *.csv 143 | *.dat 144 | *.log 145 | *.sql 146 | *.sqlite 147 | *.xml 148 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Peyton DeNiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ComfyUI-to-Python-Extension 2 | ![banner](images/comfyui_to_python_banner.png) 3 | 4 | The `ComfyUI-to-Python-Extension` is a powerful tool that translates [ComfyUI](https://github.com/comfyanonymous/ComfyUI) workflows into executable Python code. Designed to bridge the gap between ComfyUI's visual interface and Python's programming environment, this script facilitates the seamless transition from design to code execution. Whether you're a data scientist, a software developer, or an AI enthusiast, this tool streamlines the process of implementing ComfyUI workflows in Python. 5 | 6 | **Convert this:** 7 | 8 | ![SDXL UI Example](images/SDXL-UI-Example.jpg) 9 | 10 | 11 | **To this:** 12 | 13 | ``` 14 | import random 15 | import torch 16 | import sys 17 | 18 | sys.path.append("../") 19 | from nodes import ( 20 | VAEDecode, 21 | KSamplerAdvanced, 22 | EmptyLatentImage, 23 | SaveImage, 24 | CheckpointLoaderSimple, 25 | CLIPTextEncode, 26 | ) 27 | 28 | 29 | def main(): 30 | with torch.inference_mode(): 31 | checkpointloadersimple = CheckpointLoaderSimple() 32 | checkpointloadersimple_4 = checkpointloadersimple.load_checkpoint( 33 | ckpt_name="sd_xl_base_1.0.safetensors" 34 | ) 35 | 36 | emptylatentimage = EmptyLatentImage() 37 | emptylatentimage_5 = emptylatentimage.generate( 38 | width=1024, height=1024, batch_size=1 39 | ) 40 | 41 | cliptextencode = CLIPTextEncode() 42 | cliptextencode_6 = cliptextencode.encode( 43 | text="evening sunset scenery blue sky nature, glass bottle with a galaxy in it", 44 | clip=checkpointloadersimple_4[1], 45 | ) 46 | 47 | cliptextencode_7 = cliptextencode.encode( 48 | text="text, watermark", clip=checkpointloadersimple_4[1] 49 | ) 50 | 51 | checkpointloadersimple_12 = checkpointloadersimple.load_checkpoint( 52 | ckpt_name="sd_xl_refiner_1.0.safetensors" 53 | ) 54 | 55 | cliptextencode_15 = cliptextencode.encode( 56 | text="evening sunset scenery blue sky nature, glass bottle with a galaxy in it", 57 | clip=checkpointloadersimple_12[1], 58 | ) 59 | 60 | cliptextencode_16 = cliptextencode.encode( 61 | text="text, watermark", clip=checkpointloadersimple_12[1] 62 | ) 63 | 64 | ksampleradvanced = KSamplerAdvanced() 65 | vaedecode = VAEDecode() 66 | saveimage = SaveImage() 67 | 68 | for q in range(10): 69 | ksampleradvanced_10 = ksampleradvanced.sample( 70 | add_noise="enable", 71 | noise_seed=random.randint(1, 2**64), 72 | steps=25, 73 | cfg=8, 74 | sampler_name="euler", 75 | scheduler="normal", 76 | start_at_step=0, 77 | end_at_step=20, 78 | return_with_leftover_noise="enable", 79 | model=checkpointloadersimple_4[0], 80 | positive=cliptextencode_6[0], 81 | negative=cliptextencode_7[0], 82 | latent_image=emptylatentimage_5[0], 83 | ) 84 | 85 | ksampleradvanced_11 = ksampleradvanced.sample( 86 | add_noise="disable", 87 | noise_seed=random.randint(1, 2**64), 88 | steps=25, 89 | cfg=8, 90 | sampler_name="euler", 91 | scheduler="normal", 92 | start_at_step=20, 93 | end_at_step=10000, 94 | return_with_leftover_noise="disable", 95 | model=checkpointloadersimple_12[0], 96 | positive=cliptextencode_15[0], 97 | negative=cliptextencode_16[0], 98 | latent_image=ksampleradvanced_10[0], 99 | ) 100 | 101 | vaedecode_17 = vaedecode.decode( 102 | samples=ksampleradvanced_11[0], vae=checkpointloadersimple_12[2] 103 | ) 104 | 105 | saveimage_19 = saveimage.save_images( 106 | filename_prefix="ComfyUI", images=vaedecode_17[0] 107 | ) 108 | 109 | 110 | if __name__ == "__main__": 111 | main() 112 | ``` 113 | ## Potential Use Cases 114 | - Streamlining the process for creating a lean app or pipeline deployment that uses a ComfyUI workflow 115 | - Creating programmatic experiments for various prompt/parameter values 116 | - Creating large queues for image generation (For example, you could adjust the script to generate 1000 images without clicking ctrl+enter 1000 times) 117 | - Easily expanding or iterating on your architecture in Python once a foundational workflow is in place in the GUI 118 | 119 | ## V1.3.0 Release Notes 120 | - Generate .py file directly from the ComfyUI Web App 121 | 122 | ![Save As Script](images/save_as_script.png) 123 | 124 | ## V1.2.1 Release Notes 125 | - Dynamically change `comfyui_to_python.py` parameters with CLI arguments 126 | - Hotfix to handle nodes that accept kwargs. 127 | 128 | ## V1.2.0 Release Notes 129 | - Updates to adhere to latest changes from `ComfyUI` 130 | 131 | ## V1.0.0 Release Notes 132 | - **Use all the custom nodes!** 133 | - Custom nodes are now supported. If you run into any issues with code execution, first ensure that the each node works as expected in the GUI. If it works in the GUI, but not in the generated script, please submit an issue. 134 | 135 | 136 | ## Installation 137 | 138 | 139 | 1. Navigate to your `ComfyUI/custom_nodes` directory 140 | 141 | 2. Clone this repo 142 | ```bash 143 | git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git 144 | ``` 145 | 146 | After cloning the repo, your `ComfyUI` directory should look like this: 147 | ``` 148 | /comfy 149 | /comfy_extras 150 | /custom_nodes 151 | --/ComfyUI-to-Python-Extension 152 | /input 153 | /models 154 | /output 155 | /script_examples 156 | /web 157 | .gitignore 158 | LICENSE 159 | README.md 160 | comfyui_screenshot.png 161 | cuda_mollac.py 162 | execution.py 163 | extra_model_paths.yaml.example 164 | folder_paths.py 165 | latent_preview.py 166 | main.py 167 | nodes.py 168 | requirements.txt 169 | server.py 170 | ``` 171 | 172 | ## Web App Use 173 | 1. Launch ComfyUI 174 | 175 | 2. Load your favorite workflow and click `Save As Script` 176 | 177 | ![Save As Script](images/save_as_script.png) 178 | 179 | 3. Type your desired file name into the pop up screen. 180 | 181 | 4. Move .py file from your downloads folder to your `ComfyUI` directory. 182 | 183 | 5. Now you can execute the newly created .py file to generate images without launching a server. 184 | 185 | ## CLI Usage 186 | 1. Navigate to the `ComfyUI-to-Python-Extension` folder and install requirements 187 | ```bash 188 | pip install -r requirements.txt 189 | ``` 190 | 191 | 2. Launch ComfyUI, click the gear icon over `Queue Prompt`, then check `Enable Dev mode Options`. **THE SCRIPT WILL NOT WORK IF YOU DO NOT ENABLE THIS OPTION!** 192 | 193 | ![Enable Dev Mode Options](images/dev_mode_options.jpg) 194 | 195 | 3. Load up your favorite workflows, then click the newly enabled `Save (API Format)` button under Queue Prompt 196 | 197 | 4. Move the downloaded .json workflow file to your `ComfyUI/ComfyUI-to-Python-Extension` folder 198 | 199 | 5. If needed, add arguments when executing `comfyui_to_python.py` to update the default `input_file` and `output_file` to match your .json workflow file and desired .py file name. By default, the script will look for a file called `workflow_api.json`. You can also update the `queue_size` variable to your desired number of images that you want to generate in a single script execution. By default, the scripts will generate 10 images. Run `python comfyui_to_python.py --help` for more details. 200 | 201 | 6a. Run the script with default arguments: 202 | ```bash 203 | python comfyui_to_python.py 204 | ``` 205 | 6b. Run the script with optional arguments: 206 | ```bash 207 | python comfyui_to_python.py --input_file "workflow_api (2).json" --output_file my_workflow.py --queue_size 100 208 | ``` 209 | 210 | 7. After running `comfyui_to_python.py`, a new .py file will be created in the current working directory. If you made no changes, look for `workflow_api.py`. 211 | 212 | 8. Now you can execute the newly created .py file to generate images without launching a server. 213 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | from io import StringIO 5 | 6 | import traceback 7 | 8 | from aiohttp import web 9 | 10 | ext_dir = os.path.dirname(__file__) 11 | sys.path.append(ext_dir) 12 | 13 | try: 14 | import black 15 | except ImportError: 16 | print("Unable to import requirements for ComfyUI-SaveAsScript.") 17 | print("Installing...") 18 | 19 | import importlib 20 | 21 | spec = importlib.util.spec_from_file_location( 22 | "impact_install", os.path.join(os.path.dirname(__file__), "install.py") 23 | ) 24 | impact_install = importlib.util.module_from_spec(spec) 25 | spec.loader.exec_module(impact_install) 26 | 27 | print("Successfully installed. Hopefully, at least.") 28 | 29 | # Prevent reimporting of custom nodes 30 | os.environ["RUNNING_IN_COMFYUI"] = "TRUE" 31 | 32 | from comfyui_to_python import ComfyUItoPython 33 | 34 | sys.path.append(os.path.dirname(os.path.dirname(ext_dir))) 35 | 36 | import server 37 | 38 | WEB_DIRECTORY = "js" 39 | NODE_CLASS_MAPPINGS = {} 40 | 41 | 42 | @server.PromptServer.instance.routes.post("/saveasscript") 43 | async def save_as_script(request): 44 | try: 45 | data = await request.json() 46 | name = data["name"] 47 | workflow = data["workflow"] 48 | 49 | sio = StringIO() 50 | ComfyUItoPython(workflow=workflow, output_file=sio) 51 | 52 | sio.seek(0) 53 | data = sio.read() 54 | 55 | return web.Response(text=data, status=200) 56 | except Exception as e: 57 | traceback.print_exc() 58 | return web.Response(text=str(e), status=500) 59 | -------------------------------------------------------------------------------- /comfyui_to_python.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import glob 3 | import inspect 4 | import json 5 | import os 6 | import random 7 | import sys 8 | import re 9 | from typing import Dict, List, Any, Callable, Tuple, TextIO 10 | from argparse import ArgumentParser 11 | 12 | import black 13 | 14 | 15 | from comfyui_to_python_utils import ( 16 | import_custom_nodes, 17 | find_path, 18 | add_comfyui_directory_to_sys_path, 19 | add_extra_model_paths, 20 | get_value_at_index, 21 | ) 22 | 23 | add_comfyui_directory_to_sys_path() 24 | from nodes import NODE_CLASS_MAPPINGS 25 | 26 | 27 | DEFAULT_INPUT_FILE = "workflow_api.json" 28 | DEFAULT_OUTPUT_FILE = "workflow_api.py" 29 | DEFAULT_QUEUE_SIZE = 10 30 | 31 | 32 | class FileHandler: 33 | """Handles reading and writing files. 34 | 35 | This class provides methods to read JSON data from an input file and write code to an output file. 36 | """ 37 | 38 | @staticmethod 39 | def read_json_file(file_path: str | TextIO, encoding: str = "utf-8") -> dict: 40 | """ 41 | Reads a JSON file and returns its contents as a dictionary. 42 | 43 | Args: 44 | file_path (str): The path to the JSON file. 45 | 46 | Returns: 47 | dict: The contents of the JSON file as a dictionary. 48 | 49 | Raises: 50 | FileNotFoundError: If the file is not found, it lists all JSON files in the directory of the file path. 51 | ValueError: If the file is not a valid JSON. 52 | """ 53 | 54 | if hasattr(file_path, "read"): 55 | return json.load(file_path) 56 | with open(file_path, "r", encoding="utf-8") as file: 57 | data = json.load(file) 58 | return data 59 | 60 | @staticmethod 61 | def write_code_to_file(file_path: str | TextIO, code: str) -> None: 62 | """Write the specified code to a Python file. 63 | 64 | Args: 65 | file_path (str): The path to the Python file. 66 | code (str): The code to write to the file. 67 | 68 | Returns: 69 | None 70 | """ 71 | if isinstance(file_path, str): 72 | # Extract directory from the filename 73 | directory = os.path.dirname(file_path) 74 | 75 | # If the directory does not exist, create it 76 | if directory and not os.path.exists(directory): 77 | os.makedirs(directory) 78 | 79 | # Save the code to a .py file 80 | with open(file_path, "w", encoding="utf-8") as file: 81 | file.write(code) 82 | else: 83 | file_path.write(code) 84 | 85 | 86 | class LoadOrderDeterminer: 87 | """Determine the load order of each key in the provided dictionary. 88 | 89 | This class places the nodes without node dependencies first, then ensures that any node whose 90 | result is used in another node will be added to the list in the order it should be executed. 91 | 92 | Attributes: 93 | data (Dict): The dictionary for which to determine the load order. 94 | node_class_mappings (Dict): Mappings of node classes. 95 | """ 96 | 97 | def __init__(self, data: Dict, node_class_mappings: Dict): 98 | """Initialize the LoadOrderDeterminer with the given data and node class mappings. 99 | 100 | Args: 101 | data (Dict): The dictionary for which to determine the load order. 102 | node_class_mappings (Dict): Mappings of node classes. 103 | """ 104 | self.data = data 105 | self.node_class_mappings = node_class_mappings 106 | self.visited = {} 107 | self.load_order = [] 108 | self.is_special_function = False 109 | 110 | def determine_load_order(self) -> List[Tuple[str, Dict, bool]]: 111 | """Determine the load order for the given data. 112 | 113 | Returns: 114 | List[Tuple[str, Dict, bool]]: A list of tuples representing the load order. 115 | """ 116 | self._load_special_functions_first() 117 | self.is_special_function = False 118 | for key in self.data: 119 | if key not in self.visited: 120 | self._dfs(key) 121 | return self.load_order 122 | 123 | def _dfs(self, key: str) -> None: 124 | """Depth-First Search function to determine the load order. 125 | 126 | Args: 127 | key (str): The key from which to start the DFS. 128 | 129 | Returns: 130 | None 131 | """ 132 | # Mark the node as visited. 133 | self.visited[key] = True 134 | inputs = self.data[key]["inputs"] 135 | # Loop over each input key. 136 | for input_key, val in inputs.items(): 137 | # If the value is a list and the first item in the list has not been visited yet, 138 | # then recursively apply DFS on the dependency. 139 | if isinstance(val, list) and val[0] not in self.visited: 140 | self._dfs(val[0]) 141 | # Add the key and its corresponding data to the load order list. 142 | self.load_order.append((key, self.data[key], self.is_special_function)) 143 | 144 | def _load_special_functions_first(self) -> None: 145 | """Load functions without dependencies, loaderes, and encoders first. 146 | 147 | Returns: 148 | None 149 | """ 150 | # Iterate over each key in the data to check for loader keys. 151 | for key in self.data: 152 | class_def = self.node_class_mappings[self.data[key]["class_type"]]() 153 | # Check if the class is a loader class or meets specific conditions. 154 | if ( 155 | class_def.CATEGORY == "loaders" 156 | or class_def.FUNCTION in ["encode"] 157 | or not any( 158 | isinstance(val, list) for val in self.data[key]["inputs"].values() 159 | ) 160 | ): 161 | self.is_special_function = True 162 | # If the key has not been visited, perform a DFS from that key. 163 | if key not in self.visited: 164 | self._dfs(key) 165 | 166 | 167 | class CodeGenerator: 168 | """Generates Python code for a workflow based on the load order. 169 | 170 | Attributes: 171 | node_class_mappings (Dict): Mappings of node classes. 172 | base_node_class_mappings (Dict): Base mappings of node classes. 173 | """ 174 | 175 | def __init__(self, node_class_mappings: Dict, base_node_class_mappings: Dict): 176 | """Initialize the CodeGenerator with given node class mappings. 177 | 178 | Args: 179 | node_class_mappings (Dict): Mappings of node classes. 180 | base_node_class_mappings (Dict): Base mappings of node classes. 181 | """ 182 | self.node_class_mappings = node_class_mappings 183 | self.base_node_class_mappings = base_node_class_mappings 184 | 185 | def generate_workflow( 186 | self, 187 | load_order: List, 188 | queue_size: int = 10, 189 | ) -> str: 190 | """Generate the execution code based on the load order. 191 | 192 | Args: 193 | load_order (List): A list of tuples representing the load order. 194 | queue_size (int): The number of photos that will be created by the script. 195 | 196 | Returns: 197 | str: Generated execution code as a string. 198 | """ 199 | # Create the necessary data structures to hold imports and generated code 200 | import_statements, executed_variables, special_functions_code, code = ( 201 | set(["NODE_CLASS_MAPPINGS"]), 202 | {}, 203 | [], 204 | [], 205 | ) 206 | # This dictionary will store the names of the objects that we have already initialized 207 | initialized_objects = {} 208 | 209 | custom_nodes = False 210 | # Loop over each dictionary in the load order list 211 | for idx, data, is_special_function in load_order: 212 | # Generate class definition and inputs from the data 213 | inputs, class_type = data["inputs"], data["class_type"] 214 | input_types = self.node_class_mappings[class_type].INPUT_TYPES() 215 | class_def = self.node_class_mappings[class_type]() 216 | 217 | # If required inputs are not present, skip the node as it will break the code if passed through to the script 218 | missing_required_variable = False 219 | if "required" in input_types.keys(): 220 | for required in input_types["required"]: 221 | if required not in inputs.keys(): 222 | missing_required_variable = True 223 | if missing_required_variable: 224 | continue 225 | 226 | # If the class hasn't been initialized yet, initialize it and generate the import statements 227 | if class_type not in initialized_objects: 228 | # No need to use preview image nodes since we are executing the script in a terminal 229 | if class_type == "PreviewImage": 230 | continue 231 | 232 | class_type, import_statement, class_code = self.get_class_info( 233 | class_type 234 | ) 235 | initialized_objects[class_type] = self.clean_variable_name(class_type) 236 | if class_type in self.base_node_class_mappings.keys(): 237 | import_statements.add(import_statement) 238 | if class_type not in self.base_node_class_mappings.keys(): 239 | custom_nodes = True 240 | special_functions_code.append(class_code) 241 | 242 | # Get all possible parameters for class_def 243 | class_def_params = self.get_function_parameters( 244 | getattr(class_def, class_def.FUNCTION) 245 | ) 246 | no_params = class_def_params is None 247 | 248 | # Remove any keyword arguments from **inputs if they are not in class_def_params 249 | inputs = { 250 | key: value 251 | for key, value in inputs.items() 252 | if no_params or key in class_def_params 253 | } 254 | # Deal with hidden variables 255 | if ( 256 | "hidden" in input_types.keys() 257 | and "unique_id" in input_types["hidden"].keys() 258 | ): 259 | inputs["unique_id"] = random.randint(1, 2**64) 260 | elif class_def_params is not None: 261 | if "unique_id" in class_def_params: 262 | inputs["unique_id"] = random.randint(1, 2**64) 263 | 264 | # Create executed variable and generate code 265 | executed_variables[idx] = f"{self.clean_variable_name(class_type)}_{idx}" 266 | inputs = self.update_inputs(inputs, executed_variables) 267 | 268 | if is_special_function: 269 | special_functions_code.append( 270 | self.create_function_call_code( 271 | initialized_objects[class_type], 272 | class_def.FUNCTION, 273 | executed_variables[idx], 274 | is_special_function, 275 | **inputs, 276 | ) 277 | ) 278 | else: 279 | code.append( 280 | self.create_function_call_code( 281 | initialized_objects[class_type], 282 | class_def.FUNCTION, 283 | executed_variables[idx], 284 | is_special_function, 285 | **inputs, 286 | ) 287 | ) 288 | 289 | # Generate final code by combining imports and code, and wrap them in a main function 290 | final_code = self.assemble_python_code( 291 | import_statements, special_functions_code, code, queue_size, custom_nodes 292 | ) 293 | 294 | return final_code 295 | 296 | def create_function_call_code( 297 | self, 298 | obj_name: str, 299 | func: str, 300 | variable_name: str, 301 | is_special_function: bool, 302 | **kwargs, 303 | ) -> str: 304 | """Generate Python code for a function call. 305 | 306 | Args: 307 | obj_name (str): The name of the initialized object. 308 | func (str): The function to be called. 309 | variable_name (str): The name of the variable that the function result should be assigned to. 310 | is_special_function (bool): Determines the code indentation. 311 | **kwargs: The keyword arguments for the function. 312 | 313 | Returns: 314 | str: The generated Python code. 315 | """ 316 | args = ", ".join(self.format_arg(key, value) for key, value in kwargs.items()) 317 | 318 | # Generate the Python code 319 | code = f"{variable_name} = {obj_name}.{func}({args})\n" 320 | 321 | # If the code contains dependencies and is not a loader or encoder, indent the code because it will be placed inside 322 | # of a for loop 323 | if not is_special_function: 324 | code = f"\t{code}" 325 | 326 | return code 327 | 328 | def format_arg(self, key: str, value: any) -> str: 329 | """Formats arguments based on key and value. 330 | 331 | Args: 332 | key (str): Argument key. 333 | value (any): Argument value. 334 | 335 | Returns: 336 | str: Formatted argument as a string. 337 | """ 338 | if key == "noise_seed" or key == "seed": 339 | return f"{key}=random.randint(1, 2**64)" 340 | elif isinstance(value, str): 341 | value = value.replace("\n", "\\n").replace('"', "'") 342 | return f'{key}="{value}"' 343 | elif isinstance(value, dict) and "variable_name" in value: 344 | return f'{key}={value["variable_name"]}' 345 | return f"{key}={value}" 346 | 347 | def assemble_python_code( 348 | self, 349 | import_statements: set, 350 | speical_functions_code: List[str], 351 | code: List[str], 352 | queue_size: int, 353 | custom_nodes=False, 354 | ) -> str: 355 | """Generates the final code string. 356 | 357 | Args: 358 | import_statements (set): A set of unique import statements. 359 | speical_functions_code (List[str]): A list of special functions code strings. 360 | code (List[str]): A list of code strings. 361 | queue_size (int): Number of photos that will be generated by the script. 362 | custom_nodes (bool): Whether to include custom nodes in the code. 363 | 364 | Returns: 365 | str: Generated final code as a string. 366 | """ 367 | # Get the source code of the utils functions as a string 368 | func_strings = [] 369 | for func in [ 370 | get_value_at_index, 371 | find_path, 372 | add_comfyui_directory_to_sys_path, 373 | add_extra_model_paths, 374 | ]: 375 | func_strings.append(f"\n{inspect.getsource(func)}") 376 | # Define static import statements required for the script 377 | static_imports = ( 378 | [ 379 | "import os", 380 | "import random", 381 | "import sys", 382 | "from typing import Sequence, Mapping, Any, Union", 383 | "import torch", 384 | ] 385 | + func_strings 386 | + ["\n\nadd_comfyui_directory_to_sys_path()\nadd_extra_model_paths()\n"] 387 | ) 388 | # Check if custom nodes should be included 389 | if custom_nodes: 390 | static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n") 391 | custom_nodes = "import_custom_nodes()\n\t" 392 | else: 393 | custom_nodes = "" 394 | # Create import statements for node classes 395 | imports_code = [ 396 | f"from nodes import {', '.join([class_name for class_name in import_statements])}" 397 | ] 398 | # Assemble the main function code, including custom nodes if applicable 399 | main_function_code = ( 400 | "def main():\n\t" 401 | + f"{custom_nodes}with torch.inference_mode():\n\t\t" 402 | + "\n\t\t".join(speical_functions_code) 403 | + f"\n\n\t\tfor q in range({queue_size}):\n\t\t" 404 | + "\n\t\t".join(code) 405 | ) 406 | # Concatenate all parts to form the final code 407 | final_code = "\n".join( 408 | static_imports 409 | + imports_code 410 | + ["", main_function_code, "", 'if __name__ == "__main__":', "\tmain()"] 411 | ) 412 | # Format the final code according to PEP 8 using the Black library 413 | final_code = black.format_str(final_code, mode=black.Mode()) 414 | 415 | return final_code 416 | 417 | def get_class_info(self, class_type: str) -> Tuple[str, str, str]: 418 | """Generates and returns necessary information about class type. 419 | 420 | Args: 421 | class_type (str): Class type. 422 | 423 | Returns: 424 | Tuple[str, str, str]: Updated class type, import statement string, class initialization code. 425 | """ 426 | import_statement = class_type 427 | variable_name = self.clean_variable_name(class_type) 428 | if class_type in self.base_node_class_mappings.keys(): 429 | class_code = f"{variable_name} = {class_type.strip()}()" 430 | else: 431 | class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()' 432 | 433 | return class_type, import_statement, class_code 434 | 435 | @staticmethod 436 | def clean_variable_name(class_type: str) -> str: 437 | """ 438 | Remove any characters from variable name that could cause errors running the Python script. 439 | 440 | Args: 441 | class_type (str): Class type. 442 | 443 | Returns: 444 | str: Cleaned variable name with no special characters or spaces 445 | """ 446 | # Convert to lowercase and replace spaces with underscores 447 | clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_") 448 | 449 | # Remove characters that are not letters, numbers, or underscores 450 | clean_name = re.sub(r"[^a-z0-9_]", "", clean_name) 451 | 452 | # Ensure that it doesn't start with a number 453 | if clean_name[0].isdigit(): 454 | clean_name = "_" + clean_name 455 | 456 | return clean_name 457 | 458 | def get_function_parameters(self, func: Callable) -> List: 459 | """Get the names of a function's parameters. 460 | 461 | Args: 462 | func (Callable): The function whose parameters we want to inspect. 463 | 464 | Returns: 465 | List: A list containing the names of the function's parameters. 466 | """ 467 | signature = inspect.signature(func) 468 | parameters = { 469 | name: param.default if param.default != param.empty else None 470 | for name, param in signature.parameters.items() 471 | } 472 | catch_all = any( 473 | param.kind == inspect.Parameter.VAR_KEYWORD 474 | for param in signature.parameters.values() 475 | ) 476 | return list(parameters.keys()) if not catch_all else None 477 | 478 | def update_inputs(self, inputs: Dict, executed_variables: Dict) -> Dict: 479 | """Update inputs based on the executed variables. 480 | 481 | Args: 482 | inputs (Dict): Inputs dictionary to update. 483 | executed_variables (Dict): Dictionary storing executed variable names. 484 | 485 | Returns: 486 | Dict: Updated inputs dictionary. 487 | """ 488 | for key in inputs.keys(): 489 | if ( 490 | isinstance(inputs[key], list) 491 | and inputs[key][0] in executed_variables.keys() 492 | ): 493 | inputs[key] = { 494 | "variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})" 495 | } 496 | return inputs 497 | 498 | 499 | class ComfyUItoPython: 500 | """Main workflow to generate Python code from a workflow_api.json file. 501 | 502 | Attributes: 503 | input_file (str): Path to the input JSON file. 504 | output_file (str): Path to the output Python file. 505 | queue_size (int): The number of photos that will be created by the script. 506 | node_class_mappings (Dict): Mappings of node classes. 507 | base_node_class_mappings (Dict): Base mappings of node classes. 508 | """ 509 | 510 | def __init__( 511 | self, 512 | workflow: str = "", 513 | input_file: str = "", 514 | output_file: str | TextIO = "", 515 | queue_size: int = 1, 516 | node_class_mappings: Dict = NODE_CLASS_MAPPINGS, 517 | needs_init_custom_nodes: bool = False, 518 | ): 519 | """Initialize the ComfyUItoPython class with the given parameters. Exactly one of workflow or input_file must be specified. 520 | Args: 521 | workflow (str): The workflow's JSON. 522 | input_file (str): Path to the input JSON file. 523 | output_file (str | TextIO): Path to the output file or a file-like object. 524 | queue_size (int): The number of times a workflow will be executed by the script. Defaults to 1. 525 | node_class_mappings (Dict): Mappings of node classes. Defaults to NODE_CLASS_MAPPINGS. 526 | needs_init_custom_nodes (bool): Whether to initialize custom nodes. Defaults to False. 527 | """ 528 | if input_file and workflow: 529 | raise ValueError("Can't provide both input_file and workflow") 530 | elif not input_file and not workflow: 531 | raise ValueError("Needs input_file or workflow") 532 | 533 | if not output_file: 534 | raise ValueError("Needs output_file") 535 | 536 | self.workflow = workflow 537 | self.input_file = input_file 538 | self.output_file = output_file 539 | self.queue_size = queue_size 540 | self.node_class_mappings = node_class_mappings 541 | self.needs_init_custom_nodes = needs_init_custom_nodes 542 | 543 | self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings) 544 | self.execute() 545 | 546 | def execute(self): 547 | """Execute the main workflow to generate Python code. 548 | 549 | Returns: 550 | None 551 | """ 552 | # Step 1: Import all custom nodes if we need to 553 | if self.needs_init_custom_nodes: 554 | import_custom_nodes() 555 | else: 556 | # If they're already imported, we don't know which nodes are custom nodes, so we need to import all of them 557 | self.base_node_class_mappings = {} 558 | 559 | # Step 2: Read JSON data from the input file 560 | if self.input_file: 561 | data = FileHandler.read_json_file(self.input_file) 562 | else: 563 | data = json.loads(self.workflow) 564 | 565 | # Step 3: Determine the load order 566 | load_order_determiner = LoadOrderDeterminer(data, self.node_class_mappings) 567 | load_order = load_order_determiner.determine_load_order() 568 | 569 | # Step 4: Generate the workflow code 570 | code_generator = CodeGenerator( 571 | self.node_class_mappings, self.base_node_class_mappings 572 | ) 573 | generated_code = code_generator.generate_workflow( 574 | load_order, queue_size=self.queue_size 575 | ) 576 | 577 | # Step 5: Write the generated code to a file 578 | FileHandler.write_code_to_file(self.output_file, generated_code) 579 | 580 | print(f"Code successfully generated and written to {self.output_file}") 581 | 582 | 583 | def run( 584 | input_file: str = DEFAULT_INPUT_FILE, 585 | output_file: str = DEFAULT_OUTPUT_FILE, 586 | queue_size: int = DEFAULT_QUEUE_SIZE, 587 | ) -> None: 588 | """Generate Python code from a ComfyUI workflow_api.json file. 589 | 590 | Args: 591 | input_file (str): Path to the input JSON file. Defaults to "workflow_api.json". 592 | output_file (str): Path to the output Python file. 593 | Defaults to "workflow_api.py". 594 | queue_size (int): The number of times a workflow will be executed by the script. 595 | Defaults to 1. 596 | 597 | Returns: 598 | None 599 | """ 600 | ComfyUItoPython( 601 | input_file=input_file, 602 | output_file=output_file, 603 | queue_size=queue_size, 604 | needs_init_custom_nodes=True, 605 | ) 606 | 607 | 608 | def main() -> None: 609 | """Main function to generate Python code from a ComfyUI workflow_api.json file.""" 610 | parser = ArgumentParser( 611 | description="Generate Python code from a ComfyUI workflow_api.json file." 612 | ) 613 | parser.add_argument( 614 | "-f", 615 | "--input_file", 616 | type=str, 617 | help="path to the input JSON file", 618 | default=DEFAULT_INPUT_FILE, 619 | ) 620 | parser.add_argument( 621 | "-o", 622 | "--output_file", 623 | type=str, 624 | help="path to the output Python file", 625 | default=DEFAULT_OUTPUT_FILE, 626 | ) 627 | parser.add_argument( 628 | "-q", 629 | "--queue_size", 630 | type=int, 631 | help="number of times the workflow will be executed by default", 632 | default=DEFAULT_QUEUE_SIZE, 633 | ) 634 | pargs = parser.parse_args() 635 | run(**vars(pargs)) 636 | print("Done.") 637 | 638 | 639 | if __name__ == "__main__": 640 | """Run the main function.""" 641 | main() 642 | -------------------------------------------------------------------------------- /comfyui_to_python_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Sequence, Mapping, Any, Union 3 | import sys 4 | 5 | 6 | def import_custom_nodes() -> None: 7 | """Find all custom nodes in the custom_nodes folder and add those node objects to NODE_CLASS_MAPPINGS 8 | 9 | This function sets up a new asyncio event loop, initializes the PromptServer, 10 | creates a PromptQueue, and initializes the custom nodes. 11 | """ 12 | import asyncio 13 | import execution 14 | from nodes import init_extra_nodes 15 | import server 16 | 17 | # Creating a new event loop and setting it as the default loop 18 | loop = asyncio.new_event_loop() 19 | asyncio.set_event_loop(loop) 20 | 21 | # Creating an instance of PromptServer with the loop 22 | server_instance = server.PromptServer(loop) 23 | execution.PromptQueue(server_instance) 24 | 25 | # Initializing custom nodes 26 | init_extra_nodes() 27 | 28 | 29 | def find_path(name: str, path: str = None) -> str: 30 | """ 31 | Recursively looks at parent folders starting from the given path until it finds the given name. 32 | Returns the path as a Path object if found, or None otherwise. 33 | """ 34 | # If no path is given, use the current working directory 35 | if path is None: 36 | path = os.getcwd() 37 | 38 | # Check if the current directory contains the name 39 | if name in os.listdir(path): 40 | path_name = os.path.join(path, name) 41 | print(f"{name} found: {path_name}") 42 | return path_name 43 | 44 | # Get the parent directory 45 | parent_directory = os.path.dirname(path) 46 | 47 | # If the parent directory is the same as the current directory, we've reached the root and stop the search 48 | if parent_directory == path: 49 | return None 50 | 51 | # Recursively call the function with the parent directory 52 | return find_path(name, parent_directory) 53 | 54 | 55 | def add_comfyui_directory_to_sys_path() -> None: 56 | """ 57 | Add 'ComfyUI' to the sys.path 58 | """ 59 | comfyui_path = find_path("ComfyUI") 60 | if comfyui_path is not None and os.path.isdir(comfyui_path): 61 | sys.path.append(comfyui_path) 62 | print(f"'{comfyui_path}' added to sys.path") 63 | 64 | 65 | def add_extra_model_paths() -> None: 66 | """ 67 | Parse the optional extra_model_paths.yaml file and add the parsed paths to the sys.path. 68 | """ 69 | try: 70 | from main import load_extra_path_config 71 | except ImportError: 72 | print( 73 | "Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead." 74 | ) 75 | from utils.extra_config import load_extra_path_config 76 | 77 | extra_model_paths = find_path("extra_model_paths.yaml") 78 | 79 | if extra_model_paths is not None: 80 | load_extra_path_config(extra_model_paths) 81 | else: 82 | print("Could not find the extra_model_paths config file.") 83 | 84 | 85 | def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any: 86 | """Returns the value at the given index of a sequence or mapping. 87 | 88 | If the object is a sequence (like list or string), returns the value at the given index. 89 | If the object is a mapping (like a dictionary), returns the value at the index-th key. 90 | 91 | Some return a dictionary, in these cases, we look for the "results" key 92 | 93 | Args: 94 | obj (Union[Sequence, Mapping]): The object to retrieve the value from. 95 | index (int): The index of the value to retrieve. 96 | 97 | Returns: 98 | Any: The value at the given index. 99 | 100 | Raises: 101 | IndexError: If the index is out of bounds for the object and the object is not a mapping. 102 | """ 103 | try: 104 | return obj[index] 105 | except KeyError: 106 | return obj["result"][index] 107 | -------------------------------------------------------------------------------- /images/SDXL-UI-Example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydn/ComfyUI-to-Python-Extension/0aa2747736193939a3e1e8ef35aa3d0e378c60db/images/SDXL-UI-Example.PNG -------------------------------------------------------------------------------- /images/SDXL-UI-Example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydn/ComfyUI-to-Python-Extension/0aa2747736193939a3e1e8ef35aa3d0e378c60db/images/SDXL-UI-Example.jpg -------------------------------------------------------------------------------- /images/comfyui_to_python_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydn/ComfyUI-to-Python-Extension/0aa2747736193939a3e1e8ef35aa3d0e378c60db/images/comfyui_to_python_banner.png -------------------------------------------------------------------------------- /images/dev_mode_options.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydn/ComfyUI-to-Python-Extension/0aa2747736193939a3e1e8ef35aa3d0e378c60db/images/dev_mode_options.PNG -------------------------------------------------------------------------------- /images/dev_mode_options.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydn/ComfyUI-to-Python-Extension/0aa2747736193939a3e1e8ef35aa3d0e378c60db/images/dev_mode_options.jpg -------------------------------------------------------------------------------- /images/save_as_script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydn/ComfyUI-to-Python-Extension/0aa2747736193939a3e1e8ef35aa3d0e378c60db/images/save_as_script.png -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from subprocess import Popen, check_output, PIPE 5 | 6 | requirements = open(os.path.join(os.path.dirname(__file__), "requirements.txt")).read().split("\n") 7 | 8 | installed_packages = check_output( 9 | [sys.executable, "-m", "pip", "list"], 10 | universal_newlines=True 11 | ).split("\n") 12 | 13 | installed_packages = set([package.split(" ")[0].lower() for package in installed_packages if package.strip()]) 14 | 15 | for requirement in requirements: 16 | if requirement.lower() not in installed_packages: 17 | print(f"Installing requirements...") 18 | Popen([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], stdout=PIPE, stderr=PIPE, cwd=os.path.dirname(__file__)).communicate() 19 | print(f"Installed.") 20 | break 21 | -------------------------------------------------------------------------------- /js/save-as-script.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js"; 3 | import { $el } from "../../scripts/ui.js"; 4 | 5 | app.registerExtension({ 6 | name: "Comfy.SaveAsScript", 7 | init() { 8 | $el("style", { 9 | parent: document.head, 10 | }); 11 | }, 12 | async setup() { 13 | function savePythonScript() { 14 | var filename = prompt("Save script as:"); 15 | if(filename === undefined || filename === null || filename === "") { 16 | return 17 | } 18 | 19 | app.graphToPrompt().then(async (p) => { 20 | const json = JSON.stringify({name: filename + ".json", workflow: JSON.stringify(p.output, null, 2)}, null, 2); // convert the data to a JSON string 21 | var response = await api.fetchApi(`/saveasscript`, { method: "POST", body: json }); 22 | if(response.status == 200) { 23 | const blob = new Blob([await response.text()], {type: "text/python;charset=utf-8"}); 24 | const url = URL.createObjectURL(blob); 25 | if(!filename.endsWith(".py")) { 26 | filename += ".py"; 27 | } 28 | 29 | const a = $el("a", { 30 | href: url, 31 | download: filename, 32 | style: {display: "none"}, 33 | parent: document.body, 34 | }); 35 | a.click(); 36 | setTimeout(function () { 37 | a.remove(); 38 | window.URL.revokeObjectURL(url); 39 | }, 0); 40 | } 41 | }); 42 | } 43 | 44 | const menu = document.querySelector(".comfy-menu"); 45 | const separator = document.createElement("hr"); 46 | 47 | separator.style.margin = "20px 0"; 48 | separator.style.width = "100%"; 49 | menu.append(separator); 50 | 51 | const saveButton = document.createElement("button"); 52 | saveButton.textContent = "Save as Script"; 53 | saveButton.onclick = () => savePythonScript(); 54 | menu.append(saveButton); 55 | 56 | 57 | // Also load to new style menu 58 | const dropdownMenu = document.querySelectorAll(".p-menubar-submenu ")[0]; 59 | // Get submenu items 60 | const listItems = dropdownMenu.querySelectorAll("li"); 61 | let newSetsize = listItems.length; 62 | 63 | const separatorMenu = document.createElement("li"); 64 | separatorMenu.setAttribute("id", "pv_id_8_0_" + (newSetsize - 1).toString()); 65 | separatorMenu.setAttribute("class", "p-menubar-separator"); 66 | separatorMenu.setAttribute("role", "separator"); 67 | separatorMenu.setAttribute("data-pc-section", "separator"); 68 | 69 | dropdownMenu.append(separatorMenu); 70 | 71 | // Adjust list items within to increase setsize 72 | listItems.forEach((item) => { 73 | // First check if it's a separator 74 | if(item.getAttribute("data-pc-section") !== "separator") { 75 | item.setAttribute("aria-setsize", newSetsize); 76 | } 77 | }); 78 | 79 | console.log(newSetsize); 80 | 81 | // Here's the format of list items 82 | const saveButtonText = document.createElement("li"); 83 | saveButtonText.setAttribute("id", "pv_id_8_0_" + newSetsize.toString()); 84 | saveButtonText.setAttribute("class", "p-menubar-item relative"); 85 | saveButtonText.setAttribute("role", "menuitem"); 86 | saveButtonText.setAttribute("aria-label", "Save as Script"); 87 | saveButtonText.setAttribute("aria-level", "2"); 88 | saveButtonText.setAttribute("aria-setsize", newSetsize.toString()); 89 | saveButtonText.setAttribute("aria-posinset", newSetsize.toString()); 90 | saveButtonText.setAttribute("data-pc-section", "item"); 91 | saveButtonText.setAttribute("data-p-active", "false"); 92 | saveButtonText.setAttribute("data-p-focused", "false"); 93 | 94 | saveButtonText.innerHTML = ` 95 |
96 | 100 |
101 | ` 102 | 103 | saveButtonText.onclick = () => savePythonScript(); 104 | 105 | dropdownMenu.append(saveButtonText); 106 | 107 | 108 | 109 | console.log("SaveAsScript loaded"); 110 | } 111 | }); 112 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-to-python-extension" 3 | description = "This custom node allows you to generate pure python code from your ComfyUI workflow with the click of a button. Great for rapid experimentation or production deployment." 4 | version = "1.3.0" 5 | license = "LICENSE" 6 | dependencies = ["black"] 7 | 8 | [project.urls] 9 | Repository = "https://github.com/pydn/ComfyUI-to-Python-Extension" 10 | # Used by Comfy Registry https://comfyregistry.org 11 | 12 | [tool.comfy] 13 | PublisherId = "pydn" 14 | DisplayName = "ComfyUI-to-Python-Extension" 15 | Icon = "" 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black --------------------------------------------------------------------------------