Skip to content

Commit 96e8f47

Browse files
authored
Merge pull request #119 from longredzhong/main
2 parents 87ca4ce + 2ced8f5 commit 96e8f47

File tree

8 files changed

+216
-14
lines changed

8 files changed

+216
-14
lines changed

docs/Transpiler.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ Usage: python -m comfy_script.transpile [OPTIONS] WORKFLOW
4545
Options:
4646
--api TEXT [default: http://127.0.0.1:8188/]
4747
--runtime Wrap the script with runtime imports and workflow context.
48+
--args [pos|pos2orkwd|kwd] Format node inputs as positional or keyword
49+
arguments. [default: Pos2OrKwd]
4850
--help Show this message and exit.
4951
```
5052

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ build-backend = "hatchling.build"
125125
# TODO: Exclude docs and examples in sdist?
126126

127127
[tool.hatch.envs.test]
128+
features = [
129+
"default",
130+
]
128131
dependencies = [
129132
"pytest"
130133
]

src/comfy_script/astutil.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ class FloatEnum(str, Enum):
1616
import re
1717
import keyword
1818

19+
if sys.version_info < (3, 10):
20+
_dataclass_kw_only = {}
21+
else:
22+
_dataclass_kw_only = {'kw_only': True}
23+
1924
def is_xid_start(s: str) -> bool:
2025
return s.isidentifier()
2126

@@ -167,6 +172,32 @@ def to_float_enum(id: str, values: Iterable[float], indent: str) -> (str, FloatE
167172
'''
168173
return to_enum(id, { str(v): v for v in values }, indent, FloatEnum)
169174

175+
class ArgsFormat(StrEnum):
176+
'''
177+
Format function arguments as positional or keyword arguments.
178+
179+
Members are named in CamelCase to simplify CLI usage.
180+
'''
181+
182+
Pos = 'pos'
183+
'''Format as positional arguments.'''
184+
185+
Pos2OrKwd = 'pos2orkwd'
186+
'''Format as positional arguments if there are 2 or fewer args;
187+
otherwise, format as keyword arguments.
188+
'''
189+
190+
Kwd = 'kwd'
191+
'''Format as keyword arguments.'''
192+
193+
def _format_as_kwd(self, args: dict) -> bool:
194+
if self == self.Pos:
195+
return False
196+
elif self == self.Pos2OrKwd:
197+
return len(args) > 2
198+
else:
199+
return True
200+
170201
def find_spec_from_fullname(fullname: str) -> importlib.ModuleSpec | None:
171202
module, _, _ = fullname.rpartition('.')
172203
while module != '':

src/comfy_script/transpile/__init__.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from __future__ import annotations
2+
import dataclasses
23
import json
34
from pathlib import Path
45
from types import SimpleNamespace
@@ -9,10 +10,26 @@
910

1011
from .. import client
1112
from .. import astutil
13+
from ..astutil import ArgsFormat
1214
from . import passes
1315

16+
@dataclasses.dataclass(**astutil._dataclass_kw_only)
17+
class FormatOptions:
18+
runtime: bool = False
19+
'''Whether to wrap the script with runtime imports and workflow context.'''
20+
21+
args: ArgsFormat = ArgsFormat.Pos2OrKwd
22+
'''Format arguments as positional arguments or keyword arguments.'''
23+
24+
_format = FormatOptions()
25+
'''Global default format options.'''
26+
1427
class WorkflowToScriptTranspiler:
15-
def __init__(self, workflow: str | dict, api_endpoint: str = None):
28+
def __init__(
29+
self,
30+
workflow: str | dict,
31+
api_endpoint: str | None = None,
32+
):
1633
'''
1734
- `workflow`: Can be either in the web UI format or the API format.
1835
'''
@@ -47,9 +64,15 @@ def __init__(self, workflow: str | dict, api_endpoint: str = None):
4764
self.links = links
4865

4966
@staticmethod
50-
def from_image(image: Image.Image, comfyui_api: str = None) -> WorkflowToScriptTranspiler:
67+
def from_image(
68+
image: Image.Image,
69+
comfyui_api: str | None = None,
70+
) -> WorkflowToScriptTranspiler:
5171
'''
5272
Support PNG images generated by ComfyScript/ComfyUI.
73+
74+
- `image`: A PIL image containing embedded workflow or prompt metadata.
75+
- `comfyui_api`: Optional ComfyUI API endpoint to initialize the client with.
5376
'''
5477
# TODO: webp
5578
workflow = None
@@ -63,9 +86,15 @@ def from_image(image: Image.Image, comfyui_api: str = None) -> WorkflowToScriptT
6386
return WorkflowToScriptTranspiler(workflow, comfyui_api)
6487

6588
@staticmethod
66-
def from_file(path: str | Path, comfyui_api: str = None) -> WorkflowToScriptTranspiler:
89+
def from_file(
90+
path: str | Path,
91+
comfyui_api: str | None = None,
92+
) -> WorkflowToScriptTranspiler:
6793
'''
6894
Support PNG images generated by ComfyScript/ComfyUI and workflow JSON files either in the web UI format or the API format.
95+
96+
- `path`: Path to a PNG image generated by ComfyScript/ComfyUI or to a workflow JSON file in either the web UI format or the API format.
97+
- `comfyui_api`: Optional ComfyUI API endpoint to use when creating the client.
6998
'''
7099
path = Path(path)
71100
if path.suffix == '.json':
@@ -171,8 +200,32 @@ def _keyword_args_to_positional(self, node_type: str, kwargs: dict) -> list:
171200
# Optional inputs
172201
args.append({'exp': 'None', 'value': None})
173202
return args
174-
175-
def _node_to_assign_st(self, node):
203+
204+
def _format_args_as_keyword(self, node_type: str, args_dict: dict) -> list[str]:
205+
"""Format arguments as keyword arguments like 'param_name=value'."""
206+
result = []
207+
input_types = self._get_input_types(node_type)
208+
209+
for group in 'required', 'optional':
210+
group_dict: dict = input_types.get(group)
211+
if group_dict is None:
212+
continue
213+
for name in group_dict:
214+
value = args_dict.get(name)
215+
if value is not None:
216+
# Format as keyword argument
217+
result.append(f"{name}={value['exp']}")
218+
elif group == 'required':
219+
# Only include None for required parameters
220+
# Optional parameters with None value are skipped
221+
result.append(f"{name}=None")
222+
return result
223+
224+
def _node_to_assign_st(
225+
self,
226+
node,
227+
format: FormatOptions,
228+
):
176229
G = self.G
177230
links = self.links
178231

@@ -290,7 +343,14 @@ def _node_to_assign_st(self, node):
290343
if len(vars) != 0:
291344
c += f"{astutil.to_assign_target_list(vars)} = "
292345
if mode != 4:
293-
c += f"{class_id}({', '.join(arg['exp'] for arg in args)})"
346+
# JS node args must be positional for passes to work
347+
if format.args._format_as_kwd(args_dict) and not v.type in passes.JS_NODES:
348+
# Generate keyword arguments
349+
formatted_args = self._format_args_as_keyword(v.type, args_dict)
350+
c += f"{class_id}({', '.join(formatted_args)})"
351+
else:
352+
# Generate positional arguments (default behavior)
353+
c += f"{class_id}({', '.join(arg['exp'] for arg in args)})"
294354
else:
295355
# Bypass
296356
if len(vars) > 0 and len(vars_args_of_same_type) == len(vars):
@@ -364,11 +424,21 @@ def visit(node):
364424
for v in end_nodes:
365425
yield from visit(v)
366426

367-
def to_script(self, end_nodes: list[int | str] | None = None, *, runtime: bool = False) -> str:
427+
def to_script(
428+
self,
429+
end_nodes: list[int | str] | None = None,
430+
*,
431+
runtime: bool | None = None,
432+
format: FormatOptions = _format,
433+
) -> str:
368434
'''
369435
- `end_nodes`: The id can be of a different type than the type used by the workflow.
370436
- `runtime`: Whether to wrap the script with runtime imports and workflow context.
437+
- `format`: See [`FormatOptions`].
371438
'''
439+
if runtime is not None:
440+
format = dataclasses.replace(format, runtime=runtime)
441+
372442
# From leaves to roots or roots to leaves?
373443
# ComfyUI now executes workflows from leaves to roots, but there is a PR to change this to from roots to leaves with topological sort: https://github.com/comfyanonymous/ComfyUI/pull/931
374444
# To minimize future maintenance cost and suit the mental model better, we choose **from roots to leaves** too.
@@ -390,9 +460,9 @@ def to_script(self, end_nodes: list[int | str] | None = None, *, runtime: bool =
390460
c = ''
391461
for node in self._topological_generations_ordered_dfs(end_nodes):
392462
# TODO: Add line breaks if a node has multiple inputs
393-
c += self._node_to_assign_st(self.G.nodes[node])
463+
c += self._node_to_assign_st(self.G.nodes[node], format=format)
394464

395-
if runtime:
465+
if format.runtime:
396466
import textwrap
397467

398468
c = textwrap.indent(c, ' ')
@@ -408,4 +478,6 @@ def to_script(self, end_nodes: list[int | str] | None = None, *, runtime: bool =
408478

409479
__all__ = [
410480
'WorkflowToScriptTranspiler',
481+
'FormatOptions',
482+
'ArgsFormat',
411483
]

src/comfy_script/transpile/__main__.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,24 @@
77
@click.argument('workflow', type=click.File('r', encoding='utf-8'))
88
@click.option('--api', type=click.STRING, default='http://127.0.0.1:8188/', show_default=True)
99
@click.option('--runtime', is_flag=True, default=False, show_default=True, help='Wrap the script with runtime imports and workflow context.')
10-
def cli(workflow: TextIO, api: str, runtime: bool):
11-
workflow = workflow.read()
12-
script = WorkflowToScriptTranspiler(workflow, api).to_script(runtime=runtime)
10+
@click.option('--args',
11+
type=click.Choice(ArgsFormat, case_sensitive=False),
12+
default=ArgsFormat.Pos2OrKwd,
13+
show_default=True,
14+
help='Format node inputs as positional or keyword arguments.',
15+
)
16+
def cli(
17+
workflow: TextIO,
18+
api: str,
19+
runtime: bool,
20+
args: ArgsFormat,
21+
):
22+
workflow_str = workflow.read()
23+
format = FormatOptions(
24+
runtime=runtime,
25+
args=args,
26+
)
27+
script = WorkflowToScriptTranspiler(workflow_str, api).to_script(format=format)
1328
print(script)
1429

1530
cli()

src/comfy_script/transpile/passes/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ def primitive_node_elimination(ctx: AssignContext):
3030
assert new_c != ctx.c, ctx.c
3131
ctx.c = new_c
3232

33+
JS_NODES = [
34+
*REROUTE_NODES,
35+
'PrimitiveNode',
36+
'Note'
37+
]
38+
3339
SWITCH_NODES = {
3440
'HypernetworkLoader': [{'strength': 0}],
3541
'CLIPSetLastLayer': [{'stop_at_clip_layer': -1}],

tests/test.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
2+
# python -m pip install hatch
3+
mv __init__.py __init__.py.bak
4+
hatch env run -e test pytest
5+
mv __init__.py.bak __init__.py

tests/transpile/test_transpiler.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55

6-
import comfy_script.transpile as transpile
6+
from comfy_script.transpile import *
77

88
@pytest.mark.parametrize('workflow, script', [
99
('default.json',
@@ -68,5 +68,73 @@
6868
""")
6969
])
7070
def test_workflow(workflow, script):
71+
format = FormatOptions(args=ArgsFormat.Pos)
7172
with open(Path(__file__).parent / workflow) as f:
72-
assert transpile.WorkflowToScriptTranspiler(f.read()).to_script() == script
73+
assert WorkflowToScriptTranspiler(f.read()).to_script(format=format) == script
74+
75+
@pytest.mark.parametrize('workflow, script', [
76+
('default.json',
77+
r"""model, clip, vae = CheckpointLoaderSimple(ckpt_name='v1-5-pruned-emaonly.ckpt')
78+
conditioning = CLIPTextEncode(text='beautiful scenery nature glass bottle landscape, , purple galaxy bottle,', clip=clip)
79+
conditioning2 = CLIPTextEncode(text='text, watermark', clip=clip)
80+
latent = EmptyLatentImage(width=512, height=512, batch_size=1)
81+
latent = KSampler(model=model, seed=156680208700286, steps=20, cfg=8, sampler_name='euler', scheduler='normal', positive=conditioning, negative=conditioning2, latent_image=latent, denoise=1)
82+
image = VAEDecode(samples=latent, vae=vae)
83+
SaveImage(images=image, filename_prefix='ComfyUI')
84+
"""),
85+
('bypass.json',
86+
r"""image, _ = LoadImage(image='ComfyUI_temp_rcuxh_00001_.png')
87+
image2 = ImageScaleToSide(image=image, side_length=1024, side='Longest', upscale_method='nearest-exact', crop='disabled')
88+
PreviewImage(images=image2)
89+
image3, _ = CRUpscaleImage(image=image2, upscale_model='8x_NMKD-Superscale_150000_G.pth', mode='rescale', rescale_factor=2, resize_width=1024, resampling_method='lanczos', supersample='true', rounding_modulus=8)
90+
segs = ImpactMakeTileSEGS(image=image3, bbox_size=600, crop_factor=1.5, min_overlap=200, max_overlap=100, sub_batch_size_for_dilation=0, filter_segs_dilation='Reuse fast', mask_irregularity=None, irregular_mask_mode=None)
91+
# _ = SEGSPreview(segs=segs, alpha_mode=True, falloff=0.1, image=image3)
92+
image4 = image3
93+
PreviewImage(images=image4)
94+
segs2 = segs
95+
model, clip, vae = CheckpointLoaderSimple(ckpt_name=r'XL\turbovisionxlSuperFastXLBasedOnNew_alphaV0101Bakedvae.safetensors')
96+
lora_stack, _ = CRLoRAStack(switch_1='On', lora_name_1=r'xl\LCMTurboMix_LCM_Sampler.safetensors', model_weight_1=1, clip_weight_1=1, switch_2='On', lora_name_2=r'xl\xl_more_art-full_v1.safetensors', model_weight_2=1, clip_weight_2=1, switch_3='On', lora_name_3=r'xl\add-detail-xl.safetensors', model_weight_3=1, clip_weight_3=1, lora_stack=None)
97+
model, clip, _ = CRApplyLoRAStack(model=model, clip=clip, lora_stack=lora_stack)
98+
conditioning = CLIPTextEncode(text='Shot Size - extreme wide shot,( Marrakech market at night time:1.5), Moroccan young beautiful woman, smiling, exotic, (loose hijab:0.1)', clip=clip)
99+
conditioning2 = CLIPTextEncode(text='(worst quality, low quality, normal quality:2), blurry, depth of field, nsfw', clip=clip)
100+
basic_pipe = ToBasicPipe(model=model, clip=clip, vae=vae, positive=conditioning, negative=conditioning2)
101+
image5, _, _, _ = DetailerForEachPipe(image=image3, segs=segs2, guide_size=1024, guide_size_for=True, max_size=1024, seed=403808226377311, steps=10, cfg=3, sampler_name='lcm', scheduler='ddim_uniform', denoise=0.1, feather=50, noise_mask=True, force_inpaint=True, basic_pipe=basic_pipe, wildcard='', cycle=0, inpaint_model=1, noise_mask_feather=None, scheduler_func_opt=None, detailer_hook=True, refiner_ratio=50)
102+
PreviewImage(images=image5)
103+
PreviewImage(images=image)
104+
"""),
105+
('rgthree-comfy.json',
106+
r"""model, clip, vae = CheckpointLoaderSimple(ckpt_name='v1-5-pruned-emaonly.ckpt')
107+
# _ = CLIPTextEncode(text='n', clip=clip)
108+
conditioning = CLIPTextEncode(text='p', clip=clip)
109+
latent = EmptyLatentImage(width=512, height=512, batch_size=1)
110+
latent = KSampler(model=model, seed=0, steps=20, cfg=8, sampler_name='euler', scheduler='normal', positive=conditioning, negative=conditioning, latent_image=latent, denoise=1)
111+
image = VAEDecode(samples=latent, vae=vae)
112+
SaveImage(images=image, filename_prefix='ComfyUI')
113+
"""),
114+
('SplitSigmasDenoise.api.json',
115+
r"""noise = DisableNoise()
116+
width, height, _, _, _, empty_latent, _ = CRAspectRatio(width=512, height=768, aspect_ratio='custom', swap_dimensions='Off', upscale_factor=1, prescale_factor=1, batch_size=1)
117+
model = UNETLoader(unet_name='flux1-dev.safetensors', weight_dtype='fp8_e4m3fn')
118+
model = LoraLoaderModelOnly(model=model, lora_name='a.safetensors', strength_model=0.7000000000000001)
119+
model = LoraLoaderModelOnly(model=model, lora_name='b.safetensors', strength_model=0.7000000000000001)
120+
model = ModelSamplingFlux(model=model, max_shift=1.1500000000000001, base_shift=0.5, width=width, height=height)
121+
clip = DualCLIPLoader(clip_name1='t5.safetensors', clip_name2='clip_l.safetensors', type='flux')
122+
conditioning = CLIPTextEncode(text='prompt text', clip=clip)
123+
conditioning = FluxGuidance(conditioning=conditioning, guidance=3.5)
124+
guider = BasicGuider(model=model, conditioning=conditioning)
125+
sampler = KSamplerSelect(sampler_name='deis')
126+
sigmas = BasicScheduler(model=model, scheduler='beta', steps=30, denoise=1)
127+
sigmas, low_sigmas = SplitSigmasDenoise(sigmas=sigmas, denoise=0.4)
128+
noise2 = RandomNoise(noise_seed=149684926930931)
129+
empty_latent, _ = SamplerCustomAdvanced(noise=noise2, guider=guider, sampler=sampler, sigmas=sigmas, latent_image=empty_latent)
130+
empty_latent = InjectLatentNoise(latent=empty_latent, noise_seed=49328841076664, noise_strength=0.3, normalize='true')
131+
empty_latent, _ = SamplerCustomAdvanced(noise=noise, guider=guider, sampler=sampler, sigmas=low_sigmas, latent_image=empty_latent)
132+
vae = VAELoader(vae_name='ae.safetensors')
133+
image = VAEDecode(samples=empty_latent, vae=vae)
134+
SaveImage(images=image, filename_prefix='ComfyUI')
135+
""")
136+
])
137+
def test_workflow_with_keyword_args(workflow, script):
138+
format = FormatOptions(args=ArgsFormat.Kwd)
139+
with open(Path(__file__).parent / workflow) as f:
140+
assert WorkflowToScriptTranspiler(f.read()).to_script(format=format) == script

0 commit comments

Comments
 (0)