Skip to content

Commit e42b980

Browse files
committed
Module API and --output
1 parent fc76d34 commit e42b980

File tree

3 files changed

+138
-37
lines changed

3 files changed

+138
-37
lines changed

README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@ I plan to do some fresh install testing when I have time.
66

77
This is a fork maintained by Anthony Maranto of the original [ComfyUI-To-Python-Extension](https://github.com/pydn/ComfyUI-to-Python-Extension) by Peyton DeNiro. It provides a more robust command-line interface and the ability to export your current workflow as a script directly from the ComfyUI web interface.
88

9+
Once exported, this script can be run to run the workflow without a frontend, or it can be imported and the `main()` function can be used to call the workflow programatically.
10+
11+
### New Feauture: Module Support
12+
13+
Now, scripts exported with SaS can be imported as modules! Once you have a script exported, you can use it like:
14+
```python
15+
>>> import exported_script
16+
>>> results = exported_script.main("A prompt that would be sent to the command-line arguments", queue_size=1)
17+
```
18+
19+
The first `SaveImage` node reached will instead *return* the output to the calling function.
20+
21+
### New Feature: Custom Output Path
22+
23+
When running the exported script normally, you can now specify an `--output` option that will override the default path of `SaveImage` nodes.
24+
If only a single image is exported by the node, then the path will be used verbatim. Otherwise, the path will be used as a prefix, and `_#####.png` will be appended
25+
to ensure uniqueness. Note that files *will be clobbered* if only one image is exported.
26+
If the path is a directory, the `SaveImage` node's `filename_prefix` will be used.
27+
28+
If `-` is selected as the output path, normal ComfyUI output will be piped to stderr and the resultant image will be piped to stdout, allowing one to use the script
29+
like:
30+
```bash
31+
python3 script.py "A painting of outer space" --output - --queue-size 1 > image.png
32+
```
33+
934
### Usage (Web)
1035

1136
Upon installation, there will be a button labeled "Save as Script" on the interface, pictured below:
@@ -58,19 +83,34 @@ positional arguments:
5883
options:
5984
-h, --help show this help message and exit
6085
--queue-size QUEUE_SIZE, -q QUEUE_SIZE
61-
How many times the workflow will be executed (default: 10)
86+
How many times the workflow will be executed (default: 1)
6287
--comfyui-directory COMFYUI_DIRECTORY, -c COMFYUI_DIRECTORY
6388
Where to look for ComfyUI (default: current directory)
89+
--output OUTPUT, -o OUTPUT
90+
The location to save the output image. Either a file path, a directory, or - for stdout (default: the ComfyUI output directory)
91+
--disable-metadata Disables writing workflow metadata to the outputs
6492
```
6593
6694
Arguments are new. **If you have any suggestions on how to improve them or on how to effectively specify defaults in the workflow and override in the command-line**, feel free to suggest that in an Issue.
6795
96+
#### Passing Arguments to ComfyUI
97+
98+
In case you want to pass anything to the ComfyUI server as an argument, you can use `--` to indicate you're done with SaS arguments and are now passing ComfyUI arguments.
99+
For instance:
100+
101+
```bash
102+
python3 script.py "A painting of outer space" --queue-size 1 -- --cpu
103+
```
104+
68105
### Other Changes
69106
70107
#### Bugfixes
71108
- Windows paths are now properly escaped.
72109
- I also fixed what seemed to be a minor bug with exporting certain Crystools nodes, possibly due to their unusual name.
73110
111+
#### TODO
112+
- Improve compatibility with module API
113+
74114
## Old Description of ComfyUI-to-Python-Extension (usage altered)
75115
76116
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.

comfyui_to_python.py

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def can_be_imported(self, import_name: str):
183183

184184
return False
185185

186-
def generate_workflow(self, load_order: List, queue_size: int = 10) -> str:
186+
def generate_workflow(self, load_order: List, queue_size: int = 1) -> str:
187187
"""Generate the execution code based on the load order.
188188
189189
Args:
@@ -246,10 +246,19 @@ def generate_workflow(self, load_order: List, queue_size: int = 10) -> str:
246246
executed_variables[idx] = f'{self.clean_variable_name(class_type)}_{idx}'
247247
inputs = self.update_inputs(inputs, executed_variables)
248248

249-
if is_special_function:
250-
special_functions_code.append(self.create_function_call_code(initialized_objects[class_type], class_def.FUNCTION, executed_variables[idx], is_special_function, **inputs))
249+
if class_type == 'SaveImage':
250+
save_code = self.create_function_call_code(initialized_objects[class_type], class_def.FUNCTION, executed_variables[idx], is_special_function, **inputs).strip()
251+
return_code = ['if __name__ != "__main__":', '\treturn dict(' + ', '.join(self.format_arg(key, value) for key, value in inputs.items()) + ')', 'else:', '\t' + save_code]
252+
253+
if is_special_function:
254+
special_functions_code.extend(return_code)
255+
else:
256+
code.extend(return_code) ### This should presumably NEVER occur for a valid workflow
251257
else:
252-
code.append(self.create_function_call_code(initialized_objects[class_type], class_def.FUNCTION, executed_variables[idx], is_special_function, **inputs))
258+
if is_special_function:
259+
special_functions_code.append(self.create_function_call_code(initialized_objects[class_type], class_def.FUNCTION, executed_variables[idx], is_special_function, **inputs))
260+
else:
261+
code.append(self.create_function_call_code(initialized_objects[class_type], class_def.FUNCTION, executed_variables[idx], is_special_function, **inputs))
253262

254263
# Generate final code by combining imports and code, and wrap them in a main function
255264
final_code = self.assemble_python_code(import_statements, special_functions_code, arg_inputs, code, queue_size, custom_nodes)
@@ -274,11 +283,6 @@ def create_function_call_code(self, obj_name: str, func: str, variable_name: str
274283
# Generate the Python code
275284
code = f'{variable_name} = {obj_name}.{func}({args})\n'
276285

277-
# If the code contains dependencies and is not a loader or encoder, indent the code because it will be placed inside
278-
# of a for loop
279-
if not is_special_function:
280-
code = f'\t{code}'
281-
282286
return code
283287

284288
def format_arg(self, key: str, value: any) -> str:
@@ -300,7 +304,7 @@ def format_arg(self, key: str, value: any) -> str:
300304
return f'{key}={value["variable_name"]}'
301305
return f'{key}={value}'
302306

303-
def assemble_python_code(self, import_statements: set, speical_functions_code: List[str], arg_inputs: List[Tuple[str, str]], code: List[str], queue_size: int, custom_nodes=False) -> str:
307+
def assemble_python_code(self, import_statements: set, special_functions_code: List[str], arg_inputs: List[Tuple[str, str]], code: List[str], queue_size: int, custom_nodes=False) -> str:
304308
"""Generates the final code string.
305309
306310
Args:
@@ -318,32 +322,79 @@ def assemble_python_code(self, import_statements: set, speical_functions_code: L
318322
for func in PACKAGED_FUNCTIONS:
319323
func_strings.append(f'\n{inspect.getsource(func)}')
320324

321-
argparse_code = f'\nparser = argparse.ArgumentParser(description="A converted ComfyUI workflow. Required inputs listed below. Values passed should be valid JSON (assumes string if not valid JSON).")\n'
325+
argparse_code = [f'parser = argparse.ArgumentParser(description="A converted ComfyUI workflow. Required inputs listed below. Values passed should be valid JSON (assumes string if not valid JSON).")']
322326
for i, (input_name, arg_desc) in enumerate(arg_inputs):
323-
argparse_code += f'parser.add_argument("{input_name}", help="{arg_desc} (autogenerated)")\n'
324-
argparse_code += f'parser.add_argument("--queue-size", "-q", type=int, default={queue_size}, help="How many times the workflow will be executed (default: {queue_size})")\n'
325-
argparse_code += f'parser.add_argument("--comfyui-directory", "-c", default=None, help="Where to look for ComfyUI (default: current directory)")\n'
326-
argparse_code += f'parser.add_argument("--output", "-o", default=None, help="The location to save the output image -- a file path, a directory, or - for stdout (default: the ComfyUI output directory)")\n'
327-
argparse_code += f'parser.add_argument("--disable-metadata", action="store_true", help="Disables writing workflow metadata to the outputs")\n'
328-
argparse_code += 'args = parser.parse_args()\nsys.argv = [sys.argv[0]]\n'
327+
argparse_code.append(f'parser.add_argument("{input_name}", help="{arg_desc} (autogenerated)")\n')
328+
argparse_code.append(f'parser.add_argument("--queue-size", "-q", type=int, default={queue_size}, help="How many times the workflow will be executed (default: {queue_size})")\n')
329+
argparse_code.append('parser.add_argument("--comfyui-directory", "-c", default=None, help="Where to look for ComfyUI (default: current directory)")\n')
330+
argparse_code.append(f'parser.add_argument("--output", "-o", default=None, help="The location to save the output image. Either a file path, a directory, or - for stdout (default: the ComfyUI output directory)")\n')
331+
argparse_code.append(f'parser.add_argument("--disable-metadata", action="store_true", help="Disables writing workflow metadata to the outputs")\n')
332+
argparse_code.append('''
333+
comfy_args = [sys.argv[0]]
334+
if "--" in sys.argv:
335+
idx = sys.argv.index("--")
336+
comfy_args += sys.argv[idx+1:]
337+
sys.argv = sys.argv[:idx]
338+
339+
args = None
340+
if __name__ == "__main__":
341+
args = parser.parse_args()
342+
sys.argv = comfy_args
343+
if args is not None and args.output is not None and args.output == "-":
344+
ctx = contextlib.redirect_stdout(sys.stderr)
345+
else:
346+
ctx = contextlib.nullcontext()
347+
''')
329348

330349
# Define static import statements required for the script
331-
static_imports = ['import os', 'import random', 'import sys', 'import json', 'import argparse', 'from typing import Sequence, Mapping, Any, Union',
332-
'import torch'] + func_strings + [argparse_code]
350+
static_imports = ['import os', 'import random', 'import sys', 'import json', 'import argparse', 'import contextlib', 'from typing import Sequence, Mapping, Any, Union',
351+
'import torch'] + func_strings + argparse_code
333352
# Check if custom nodes should be included
334353
if custom_nodes:
335354
static_imports.append(f'\n{inspect.getsource(import_custom_nodes)}\n')
336-
custom_nodes = 'import_custom_nodes()\n\t'
337-
else:
338-
custom_nodes = ''
339-
static_imports += ['\n\nadd_comfyui_directory_to_sys_path()\nadd_extra_model_paths()\n']
340-
# Create import statements for node classes
341-
imports_code = [f"from nodes import {', '.join([class_name for class_name in import_statements])}", '']
355+
newline_doubletab = '\n\t\t' # You can't use backslashes in f-strings
356+
newline_tripletab = '\n\t\t\t' # Same
342357
# Assemble the main function code, including custom nodes if applicable
343-
main_function_code = "def main():\n\t" + f'{custom_nodes}with torch.inference_mode():\n\t\t' + '\n\t\t'.join(speical_functions_code) \
344-
+ f'\n\n\t\tfor q in range(args.queue_size):\n\t\t' + '\n\t\t'.join(code)
358+
main_function_code = f"""
359+
_custom_nodes_imported = {str(not custom_nodes)}
360+
_custom_path_added = False
361+
362+
def main(*func_args, **func_kwargs):
363+
global args, _custom_nodes_imported, _custom_path_added
364+
if __name__ == "__main__":
365+
if args is None:
366+
args = parser.parse_args()
367+
else:
368+
defaults = dict((arg, parser.get_default(arg)) for arg in ['queue_size', 'comfyui_directory', 'output', 'disable_metadata'])
369+
ordered_args = dict(zip({[input_name for input_name, _ in arg_inputs]}, func_args))
370+
371+
all_args = dict()
372+
all_args.update(defaults)
373+
all_args.update(ordered_args)
374+
all_args.update(func_kwargs)
375+
376+
args = argparse.Namespace(**all_args)
377+
378+
with ctx:
379+
if not _custom_path_added:
380+
add_comfyui_directory_to_sys_path()
381+
add_extra_model_paths()
382+
383+
_custom_path_added = True
384+
385+
if not _custom_nodes_imported:
386+
import_custom_nodes()
387+
388+
_custom_nodes_imported = True
389+
390+
from nodes import {', '.join([class_name for class_name in import_statements])}
391+
392+
with torch.inference_mode(), ctx:
393+
{newline_doubletab.join(special_functions_code)}
394+
for q in range(args.queue_size):
395+
{newline_tripletab.join(code)}""".replace(" ", "\t")
345396
# Concatenate all parts to form the final code
346-
final_code = '\n'.join(static_imports + imports_code + [main_function_code, '', 'if __name__ == "__main__":', '\tmain()'])
397+
final_code = '\n'.join(static_imports + [main_function_code, '', 'if __name__ == "__main__":', '\tmain()'])
347398
# Format the final code according to PEP 8 using the Black library
348399
final_code = black.format_str(final_code, mode=black.Mode())
349400

@@ -363,7 +414,7 @@ def get_class_info(self, class_type: str) -> Tuple[str, str, str]:
363414
before = ""
364415
after = ""
365416
if class_type.strip() == 'SaveImage':
366-
before = 'save_image_wrapper('
417+
before = 'save_image_wrapper(' + 'ctx, '
367418
after = ')'
368419

369420
if self.can_be_imported(class_type):
@@ -437,15 +488,15 @@ class ComfyUItoPython:
437488
base_node_class_mappings (Dict): Base mappings of node classes.
438489
"""
439490

440-
def __init__(self, workflow: str = "", input_file: str = "", output_file: (str | TextIO) = "", queue_size: int = 10, node_class_mappings: Dict = NODE_CLASS_MAPPINGS,
491+
def __init__(self, workflow: str = "", input_file: str = "", output_file: (str | TextIO) = "", queue_size: int = 1, node_class_mappings: Dict = NODE_CLASS_MAPPINGS,
441492
needs_init_custom_nodes: bool = False):
442493
"""Initialize the ComfyUItoPython class with the given parameters. Exactly one of workflow or input_file must be specified.
443494
444495
Args:
445496
workflow (str): The workflow's JSON.
446497
input_file (str): Path to the input JSON file.
447498
output_file (str | TextIO): Path to the output file or a file-like object.
448-
queue_size (int): The number of times a workflow will be executed by the script. Defaults to 10.
499+
queue_size (int): The number of times a workflow will be executed by the script. Defaults to 1.
449500
node_class_mappings (Dict): Mappings of node classes. Defaults to NODE_CLASS_MAPPINGS.
450501
needs_init_custom_nodes (bool): Whether to initialize custom nodes. Defaults to False.
451502
"""

comfyui_to_python_utils.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,17 @@ def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
106106
except KeyError:
107107
return obj['result'][index]
108108

109-
def parse_arg(s: str):
109+
def parse_arg(s: Any):
110110
""" Parses a JSON string, returning it unchanged if the parsing fails. """
111+
if __name__ == "__main__" or not isinstance(s, str):
112+
return s
113+
111114
try:
112115
return json.loads(s)
113116
except json.JSONDecodeError:
114117
return s
115118

116-
def save_image_wrapper(cls):
119+
def save_image_wrapper(context, cls):
117120
if args.output is None: return cls
118121

119122
from PIL import Image, ImageOps, ImageSequence
@@ -146,7 +149,14 @@ def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pngi
146149
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
147150

148151
if args.output == "-":
149-
img.save(sys.stdout.buffer, format="png", pnginfo=metadata, compress_level=self.compress_level)
152+
# Hack to briefly restore stdout
153+
if context is not None:
154+
context.__exit__(None, None, None)
155+
try:
156+
img.save(sys.stdout.buffer, format="png", pnginfo=metadata, compress_level=self.compress_level)
157+
finally:
158+
if context is not None:
159+
context.__enter__()
150160
else:
151161
subfolder = ""
152162
if len(images) == 1:
@@ -160,7 +170,7 @@ def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pngi
160170
else:
161171
if os.path.isdir(args.output):
162172
subfolder = args.output
163-
file = "output"
173+
file = filename_prefix
164174
else:
165175
subfolder, file = os.path.split(args.output)
166176

@@ -171,7 +181,7 @@ def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pngi
171181
file_pattern = file
172182
while True:
173183
filename_with_batch_num = file_pattern.replace("%batch_num%", str(batch_number))
174-
file = f"{filename_with_batch_num}_{self.counter:05}_.png"
184+
file = f"{filename_with_batch_num}_{self.counter:05}.png"
175185
self.counter += 1
176186

177187
if file not in files:

0 commit comments

Comments
 (0)