aleph65 commited on
Commit
cfdb90d
·
verified ·
1 Parent(s): 96bca35

Add API scripts (upload/download/run/convert) and API-format headswap workflow

Browse files
workflows/api/head-swap-flux-klein.json ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "109": {
3
+ "class_type": "SamplerCustomAdvanced",
4
+ "inputs": {
5
+ "guider": [
6
+ "123",
7
+ 0
8
+ ],
9
+ "latent_image": [
10
+ "113",
11
+ 0
12
+ ],
13
+ "noise": [
14
+ "110",
15
+ 0
16
+ ],
17
+ "sampler": [
18
+ "125",
19
+ 0
20
+ ],
21
+ "sigmas": [
22
+ "122",
23
+ 0
24
+ ]
25
+ }
26
+ },
27
+ "110": {
28
+ "class_type": "RandomNoise",
29
+ "inputs": {
30
+ "noise_seed": 432262096973493
31
+ }
32
+ },
33
+ "111": {
34
+ "class_type": "VAELoader",
35
+ "inputs": {
36
+ "vae_name": "flux2-vae.safetensors"
37
+ }
38
+ },
39
+ "112": {
40
+ "class_type": "GetImageSize",
41
+ "inputs": {
42
+ "image": [
43
+ "120",
44
+ 0
45
+ ]
46
+ }
47
+ },
48
+ "113": {
49
+ "class_type": "EmptyFlux2LatentImage",
50
+ "inputs": {
51
+ "batch_size": 1,
52
+ "height": [
53
+ "112",
54
+ 1
55
+ ],
56
+ "width": [
57
+ "112",
58
+ 0
59
+ ]
60
+ }
61
+ },
62
+ "114:76": {
63
+ "class_type": "ReferenceLatent",
64
+ "inputs": {
65
+ "conditioning": [
66
+ "116:76",
67
+ 0
68
+ ],
69
+ "latent": [
70
+ "114:78",
71
+ 0
72
+ ]
73
+ }
74
+ },
75
+ "114:77": {
76
+ "class_type": "ReferenceLatent",
77
+ "inputs": {
78
+ "conditioning": [
79
+ "116:77",
80
+ 0
81
+ ],
82
+ "latent": [
83
+ "114:78",
84
+ 0
85
+ ]
86
+ }
87
+ },
88
+ "114:78": {
89
+ "class_type": "VAEEncode",
90
+ "inputs": {
91
+ "pixels": [
92
+ "121",
93
+ 0
94
+ ],
95
+ "vae": [
96
+ "111",
97
+ 0
98
+ ]
99
+ }
100
+ },
101
+ "115": {
102
+ "class_type": "CLIPLoader",
103
+ "inputs": {
104
+ "clip_name": "qwen_3_8b_fp8mixed.safetensors",
105
+ "device": "default",
106
+ "type": "flux2"
107
+ }
108
+ },
109
+ "116:76": {
110
+ "class_type": "ReferenceLatent",
111
+ "inputs": {
112
+ "conditioning": [
113
+ "119",
114
+ 0
115
+ ],
116
+ "latent": [
117
+ "116:78",
118
+ 0
119
+ ]
120
+ }
121
+ },
122
+ "116:77": {
123
+ "class_type": "ReferenceLatent",
124
+ "inputs": {
125
+ "conditioning": [
126
+ "117",
127
+ 0
128
+ ],
129
+ "latent": [
130
+ "116:78",
131
+ 0
132
+ ]
133
+ }
134
+ },
135
+ "116:78": {
136
+ "class_type": "VAEEncode",
137
+ "inputs": {
138
+ "pixels": [
139
+ "120",
140
+ 0
141
+ ],
142
+ "vae": [
143
+ "111",
144
+ 0
145
+ ]
146
+ }
147
+ },
148
+ "117": {
149
+ "_meta": {
150
+ "title": "CLIP Text Encode (Positive Prompt)"
151
+ },
152
+ "class_type": "CLIPTextEncode",
153
+ "inputs": {
154
+ "clip": [
155
+ "115",
156
+ 0
157
+ ],
158
+ "text": "head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. remove the head from Picture 1 completely and replace it with the head from Picture 2, strictly preserving the hair, eye color, nose structure of Picture 2. copy the direction of the eye, head rotation, micro expressions from Picture 1, high quality, sharp details, 4k.\n\nDescribe the expression in Picture 1 and copy it to the new image.\n"
159
+ }
160
+ },
161
+ "118": {
162
+ "class_type": "UNETLoader",
163
+ "inputs": {
164
+ "unet_name": "flux-2-klein-9b.safetensors",
165
+ "weight_dtype": "default"
166
+ }
167
+ },
168
+ "119": {
169
+ "_meta": {
170
+ "title": "CLIP Text Encode ( Negative Prompt)"
171
+ },
172
+ "class_type": "CLIPTextEncode",
173
+ "inputs": {
174
+ "clip": [
175
+ "115",
176
+ 0
177
+ ],
178
+ "text": "bad quality, noise, blurry, worst quality, low resolution, blur, distortion, unnatural blending, cartoon, illustration, painting"
179
+ }
180
+ },
181
+ "120": {
182
+ "class_type": "ResizeImageMaskNode",
183
+ "inputs": {
184
+ "input": [
185
+ "76",
186
+ 0
187
+ ],
188
+ "resize_type": "scale total pixels",
189
+ "resize_type.megapixels": 1.5,
190
+ "scale_method": "lanczos"
191
+ }
192
+ },
193
+ "121": {
194
+ "class_type": "ResizeImageMaskNode",
195
+ "inputs": {
196
+ "input": [
197
+ "187",
198
+ 0
199
+ ],
200
+ "resize_type": "scale total pixels",
201
+ "resize_type.megapixels": 1.5,
202
+ "scale_method": "lanczos"
203
+ }
204
+ },
205
+ "122": {
206
+ "class_type": "Flux2Scheduler",
207
+ "inputs": {
208
+ "height": [
209
+ "112",
210
+ 1
211
+ ],
212
+ "steps": 8,
213
+ "width": [
214
+ "112",
215
+ 0
216
+ ]
217
+ }
218
+ },
219
+ "123": {
220
+ "class_type": "CFGGuider",
221
+ "inputs": {
222
+ "cfg": 1,
223
+ "model": [
224
+ "190",
225
+ 0
226
+ ],
227
+ "negative": [
228
+ "114:76",
229
+ 0
230
+ ],
231
+ "positive": [
232
+ "114:77",
233
+ 0
234
+ ]
235
+ }
236
+ },
237
+ "125": {
238
+ "class_type": "KSamplerSelect",
239
+ "inputs": {
240
+ "sampler_name": "lcm"
241
+ }
242
+ },
243
+ "131": {
244
+ "class_type": "VAEDecode",
245
+ "inputs": {
246
+ "samples": [
247
+ "109",
248
+ 0
249
+ ],
250
+ "vae": [
251
+ "111",
252
+ 0
253
+ ]
254
+ }
255
+ },
256
+ "133": {
257
+ "class_type": "LoraLoaderModelOnly",
258
+ "inputs": {
259
+ "lora_name": "flux2/klein/bfs_head_v1_flux-klein_9b_step3500_rank128.safetensors",
260
+ "model": [
261
+ "118",
262
+ 0
263
+ ],
264
+ "strength_model": 1
265
+ }
266
+ },
267
+ "160": {
268
+ "class_type": "ImageCropByMask",
269
+ "inputs": {
270
+ "image": [
271
+ "81",
272
+ 0
273
+ ],
274
+ "mask": [
275
+ "81",
276
+ 1
277
+ ]
278
+ }
279
+ },
280
+ "163": {
281
+ "class_type": "SaveImage",
282
+ "inputs": {
283
+ "filename_prefix": "headswap_flux_klein_optimized",
284
+ "images": [
285
+ "131",
286
+ 0
287
+ ]
288
+ }
289
+ },
290
+ "176": {
291
+ "class_type": "ImageConcanate",
292
+ "inputs": {
293
+ "direction": "down",
294
+ "image1": [
295
+ "120",
296
+ 0
297
+ ],
298
+ "image2": [
299
+ "121",
300
+ 0
301
+ ],
302
+ "match_image_size": true
303
+ }
304
+ },
305
+ "177": {
306
+ "class_type": "ImageConcanate",
307
+ "inputs": {
308
+ "direction": "right",
309
+ "image1": [
310
+ "176",
311
+ 0
312
+ ],
313
+ "image2": [
314
+ "131",
315
+ 0
316
+ ],
317
+ "match_image_size": true
318
+ }
319
+ },
320
+ "178": {
321
+ "class_type": "PreviewImage",
322
+ "inputs": {
323
+ "images": [
324
+ "177",
325
+ 0
326
+ ]
327
+ }
328
+ },
329
+ "187": {
330
+ "class_type": "RMBG",
331
+ "inputs": {
332
+ "background": "Alpha",
333
+ "image": [
334
+ "81",
335
+ 0
336
+ ],
337
+ "invert_output": false,
338
+ "mask_blur": 0,
339
+ "mask_offset": 0,
340
+ "model": "RMBG-2.0",
341
+ "process_res": 1024,
342
+ "refine_foreground": false,
343
+ "sensitivity": 1
344
+ }
345
+ },
346
+ "190": {
347
+ "_meta": {
348
+ "title": "Lissie lora"
349
+ },
350
+ "class_type": "LoraLoaderModelOnly",
351
+ "inputs": {
352
+ "lora_name": "lissie_flux2_klein.safetensors",
353
+ "model": [
354
+ "133",
355
+ 0
356
+ ],
357
+ "strength_model": 1
358
+ }
359
+ },
360
+ "76": {
361
+ "_meta": {
362
+ "title": "Body Reference"
363
+ },
364
+ "class_type": "LoadImage",
365
+ "inputs": {
366
+ "image": "general_image.jpg"
367
+ }
368
+ },
369
+ "81": {
370
+ "_meta": {
371
+ "title": "Face Reference"
372
+ },
373
+ "class_type": "LoadImage",
374
+ "inputs": {
375
+ "image": "reference_face.JPG"
376
+ }
377
+ }
378
+ }
workflows/scripts/convert_workflow.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Convert a ComfyUI workflow from UI format (Save/Workflow menu) to API format.
3
+
4
+ Equivalent to the frontend's Workflow > Export (API), but headless. Needs a
5
+ running ComfyUI instance because widget-to-input mapping comes from its
6
+ GET /object_info (so the instance must have the same custom nodes installed
7
+ as the workflow uses).
8
+
9
+ Handles: subgraphs (flattened, ids become "<instance>:<inner>"), Reroute
10
+ splicing, bypassed (mode 4) nodes with type-matched pass-through, muted
11
+ (mode 2) nodes, widget-converted-to-input entries, and the phantom
12
+ control_after_generate value that follows every seed widget.
13
+
14
+ Example:
15
+ python convert_workflow.py --url http://127.0.0.1:7865 \
16
+ ../head-swap-flux-klein.json -o ../api/head-swap-flux-klein.json
17
+ """
18
+ import argparse
19
+ import json
20
+ import pathlib
21
+ import sys
22
+
23
+ import requests
24
+
25
+ VIRTUAL_TYPES = {"Reroute", "Note", "MarkdownNote", "PrimitiveNode"}
26
+ WIDGET_SCALAR_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}
27
+ SUBGRAPH_INPUT_ID = -10
28
+ SUBGRAPH_OUTPUT_ID = -20
29
+
30
+
31
+ class Scope:
32
+ """One graph namespace: either the root workflow or a subgraph instance."""
33
+
34
+ def __init__(self, graph, prefix="", parent=None, instance_id=None):
35
+ self.nodes = {n["id"]: n for n in graph.get("nodes", [])}
36
+ self.links = {}
37
+ for l in graph.get("links", []):
38
+ if isinstance(l, dict): # subgraph defs store links as objects
39
+ self.links[l["id"]] = (l["origin_id"], l["origin_slot"], l["target_id"], l["target_slot"])
40
+ else: # root stores [id, src, src_slot, dst, dst_slot, type]
41
+ self.links[l[0]] = (l[1], l[2], l[3], l[4])
42
+ self.prefix = prefix
43
+ self.parent = parent # (parent Scope, instance node id) or None
44
+ self.instance_id = instance_id
45
+ self.children = {} # instance node id -> child Scope
46
+
47
+ def fid(self, node_id):
48
+ return f"{self.prefix}{node_id}"
49
+
50
+
51
+ class Converter:
52
+ def __init__(self, workflow, object_info):
53
+ self.oi = object_info
54
+ self.subgraph_defs = {
55
+ sg["id"]: sg for sg in workflow.get("definitions", {}).get("subgraphs", [])
56
+ }
57
+ self.root = Scope(workflow)
58
+ self._build_children(self.root)
59
+
60
+ def _build_children(self, scope):
61
+ for nid, node in scope.nodes.items():
62
+ sg = self.subgraph_defs.get(node["type"])
63
+ if sg is not None:
64
+ child = Scope(sg, prefix=f"{scope.fid(nid)}:", parent=(scope, nid), instance_id=nid)
65
+ scope.children[nid] = child
66
+ self._build_children(child)
67
+
68
+ def all_scopes(self):
69
+ stack = [self.root]
70
+ while stack:
71
+ s = stack.pop()
72
+ yield s
73
+ stack.extend(s.children.values())
74
+
75
+ def resolve(self, scope, link_id):
76
+ """Follow a link upstream to a real executable (node_fid, output_slot), or None."""
77
+ if link_id is None or link_id not in scope.links:
78
+ return None
79
+ origin_id, origin_slot, _, _ = scope.links[link_id]
80
+
81
+ if origin_id == SUBGRAPH_INPUT_ID: # crossed into this subgraph from outside
82
+ parent_scope, instance_id = scope.parent
83
+ instance = parent_scope.nodes[instance_id]
84
+ outer_link = instance["inputs"][origin_slot].get("link")
85
+ return self.resolve(parent_scope, outer_link)
86
+
87
+ node = scope.nodes[origin_id]
88
+ ntype = node["type"]
89
+
90
+ if ntype in self.subgraph_defs: # source is a subgraph instance: descend
91
+ child = scope.children[origin_id]
92
+ for lid, (_, _, tid, tslot) in child.links.items():
93
+ if tid == SUBGRAPH_OUTPUT_ID and tslot == origin_slot:
94
+ return self.resolve(child, lid)
95
+ return None
96
+
97
+ if ntype == "Reroute":
98
+ return self.resolve(scope, node["inputs"][0].get("link"))
99
+
100
+ mode = node.get("mode", 0)
101
+ if mode == 2: # muted: acts as removed
102
+ return None
103
+ if mode == 4: # bypassed: pass through first linked input of matching type
104
+ out_type = node["outputs"][origin_slot].get("type", "*")
105
+ for inp in node.get("inputs", []):
106
+ if inp.get("link") is None:
107
+ continue
108
+ in_type = inp.get("type", "*")
109
+ if in_type == out_type or "*" in (in_type, out_type):
110
+ return self.resolve(scope, inp["link"])
111
+ return None
112
+
113
+ return (scope.fid(origin_id), origin_slot)
114
+
115
+ def widget_inputs(self, node):
116
+ """Map a node's widgets_values onto named inputs using /object_info order."""
117
+ spec = self.oi[node["type"]].get("input", {})
118
+ ordered = list(spec.get("required", {}).items()) + list(spec.get("optional", {}).items())
119
+ values = node.get("widgets_values") or []
120
+ if isinstance(values, dict): # some packs (rgthree) serialize by name
121
+ widget_names = {name for name, s in ordered if self._is_widget(s)}
122
+ return {k: v for k, v in values.items() if k in widget_names}
123
+ result = {}
124
+ self._consume_widgets(ordered, list(values), result)
125
+ return result
126
+
127
+ def _consume_widgets(self, ordered, queue, result, prefix=""):
128
+ for name, s in ordered:
129
+ if not queue:
130
+ return
131
+ opts = s[1] if len(s) > 1 and isinstance(s[1], dict) else {}
132
+ if s[0] == "COMFY_DYNAMICCOMBO_V3":
133
+ # the selection is one widget value; the chosen option pulls in
134
+ # nested inputs addressed with dotted ids ("resize_type.megapixels")
135
+ selected = queue.pop(0)
136
+ result[f"{prefix}{name}"] = selected
137
+ option = next((o for o in opts.get("options", []) if o["key"] == selected), None)
138
+ if option:
139
+ inner = option["inputs"]
140
+ inner_ordered = (list(inner.get("required", {}).items())
141
+ + list(inner.get("optional", {}).items()))
142
+ self._consume_widgets(inner_ordered, queue, result, prefix=f"{prefix}{name}.")
143
+ elif self._is_widget(s):
144
+ result[f"{prefix}{name}"] = queue.pop(0)
145
+ if opts.get("control_after_generate") and queue:
146
+ queue.pop(0) # discard the UI-only fixed/increment/randomize widget
147
+
148
+ @staticmethod
149
+ def _is_widget(s):
150
+ kind = s[0]
151
+ opts = s[1] if len(s) > 1 and isinstance(s[1], dict) else {}
152
+ if opts.get("forceInput"):
153
+ return False
154
+ return isinstance(kind, list) or kind in WIDGET_SCALAR_TYPES
155
+
156
+ def convert(self):
157
+ prompt = {}
158
+ for scope in self.all_scopes():
159
+ for nid, node in scope.nodes.items():
160
+ ntype = node["type"]
161
+ if (ntype in VIRTUAL_TYPES or ntype in self.subgraph_defs
162
+ or node.get("mode", 0) in (2, 4)):
163
+ continue
164
+ if ntype not in self.oi:
165
+ raise SystemExit(
166
+ f"ERROR: node {scope.fid(nid)} has type {ntype!r} which the ComfyUI "
167
+ "instance does not know -- install its custom nodes first")
168
+ inputs = self.widget_inputs(node)
169
+ for inp in node.get("inputs", []):
170
+ source = self.resolve(scope, inp.get("link"))
171
+ if source is not None:
172
+ inputs[inp["name"]] = list(source)
173
+ elif inp.get("link") is not None and not inp.get("widget"):
174
+ print(f"warning: {scope.fid(nid)} ({ntype}) input {inp['name']!r} "
175
+ "leads to a muted/bypassed dead end; leaving unconnected",
176
+ file=sys.stderr)
177
+ entry = {"class_type": ntype, "inputs": inputs}
178
+ title = node.get("title")
179
+ if title:
180
+ entry["_meta"] = {"title": title}
181
+ prompt[scope.fid(nid)] = entry
182
+ return prompt
183
+
184
+
185
+ def main():
186
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
187
+ ap.add_argument("--url", required=True, help="ComfyUI base URL (source of /object_info)")
188
+ ap.add_argument("workflow", help="UI-format workflow JSON")
189
+ ap.add_argument("-o", "--output", help="where to write the API JSON (default: stdout)")
190
+ args = ap.parse_args()
191
+
192
+ wf = json.loads(pathlib.Path(args.workflow).read_text())
193
+ if "nodes" not in wf:
194
+ sys.exit("ERROR: input already looks like API format (no 'nodes' array)")
195
+
196
+ object_info = requests.get(f"{args.url.rstrip('/')}/object_info", timeout=120).json()
197
+ prompt = Converter(wf, object_info).convert()
198
+ out = json.dumps(prompt, indent=2, sort_keys=True)
199
+ if args.output:
200
+ pathlib.Path(args.output).write_text(out + "\n")
201
+ print(f"wrote {args.output} ({len(prompt)} nodes)", file=sys.stderr)
202
+ else:
203
+ print(out)
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
workflows/scripts/download_files.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Download files from a ComfyUI instance's output (or input/temp) folder.
3
+
4
+ Uses ComfyUI's built-in GET /view endpoint. File names may include a
5
+ subfolder ("qwen_edit/2026-07-07/img_00001_.webp") -- the same strings
6
+ run_workflow.py prints when a job finishes. Prints the local path of each
7
+ downloaded file to stdout.
8
+
9
+ Examples:
10
+ python download_files.py --url https://POD-7865.proxy.runpod.net headswap_00001_.png
11
+ python download_files.py --url http://127.0.0.1:7865 --out results a/x.png b/y.png
12
+ """
13
+ import argparse
14
+ import pathlib
15
+ import sys
16
+
17
+ import requests
18
+
19
+
20
+ def download_file(base_url, name, folder_type="output", out_dir=".", timeout=300):
21
+ """Download one file by its ComfyUI reference name; returns the local path."""
22
+ ref = pathlib.PurePosixPath(name)
23
+ r = requests.get(
24
+ f"{base_url.rstrip('/')}/view",
25
+ params={
26
+ "filename": ref.name,
27
+ "subfolder": str(ref.parent) if str(ref.parent) != "." else "",
28
+ "type": folder_type,
29
+ },
30
+ timeout=timeout,
31
+ )
32
+ r.raise_for_status()
33
+ dest = pathlib.Path(out_dir) / name
34
+ dest.parent.mkdir(parents=True, exist_ok=True)
35
+ dest.write_bytes(r.content)
36
+ return dest
37
+
38
+
39
+ def main():
40
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
41
+ ap.add_argument("--url", required=True, help="ComfyUI base URL, e.g. https://POD-7865.proxy.runpod.net")
42
+ ap.add_argument("--type", default="output", choices=["output", "input", "temp"],
43
+ help="which ComfyUI folder to read from (default: output)")
44
+ ap.add_argument("--out", default=".", help="local directory to save into (default: cwd)")
45
+ ap.add_argument("names", nargs="+", help='file names, optionally with subfolder ("sub/file.png")')
46
+ args = ap.parse_args()
47
+
48
+ failed = False
49
+ for name in args.names:
50
+ try:
51
+ dest = download_file(args.url, name, args.type, args.out)
52
+ except (OSError, requests.RequestException) as e:
53
+ print(f"ERROR downloading {name}: {e}", file=sys.stderr)
54
+ failed = True
55
+ continue
56
+ print(f"downloaded {args.type}/{name} -> {dest} ({dest.stat().st_size} bytes)", file=sys.stderr)
57
+ print(dest)
58
+ sys.exit(1 if failed else 0)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
workflows/scripts/run_workflow.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run an API-format ComfyUI workflow synchronously and wait for it to finish.
3
+
4
+ Queues the workflow via POST /prompt, polls GET /history/{prompt_id} until the
5
+ job completes, then prints one reference name per produced output file to
6
+ stdout ("file.png" or "subfolder/file.png") -- feed those straight to
7
+ download_files.py. Exits non-zero on validation or execution failure, with
8
+ ComfyUI's error details on stderr.
9
+
10
+ The workflow must be API format (flat {"<id>": {"class_type": ...}} dict).
11
+ Export one from the UI with Workflow > Export (API), or convert a saved UI
12
+ JSON with convert_workflow.py.
13
+
14
+ Runtime patching (so workflow files stay generic):
15
+ --set NODE_ID.INPUT=VALUE override any node input, e.g. --set 76.image=body.jpg
16
+ (VALUE is parsed as JSON when possible, else kept as string)
17
+ --prefix PREFIX set filename_prefix on every node that has one,
18
+ e.g. --prefix "headswap/%year%%month%%day%-%hour%%minute%%second%"
19
+ --randomize-seeds randomize every seed/noise_seed input (on by default;
20
+ disable with --keep-seeds for reproducible reruns)
21
+
22
+ Example:
23
+ python run_workflow.py --url https://POD-7865.proxy.runpod.net \
24
+ ../api/head-swap-flux-klein.json \
25
+ --set 76.image=body.jpg --set 81.image=head.jpg \
26
+ --prefix "headswap/%year%%month%%day%-%hour%%minute%%second%"
27
+ """
28
+ import argparse
29
+ import json
30
+ import pathlib
31
+ import random
32
+ import sys
33
+ import time
34
+ import uuid
35
+
36
+ import requests
37
+
38
+ SEED_INPUT_NAMES = ("seed", "noise_seed")
39
+
40
+
41
+ def parse_set(spec):
42
+ """'76.image=body.jpg' -> ('76', 'image', 'body.jpg'). Values parse as JSON when possible."""
43
+ try:
44
+ target, raw = spec.split("=", 1)
45
+ node_id, input_name = target.split(".", 1)
46
+ except ValueError:
47
+ raise argparse.ArgumentTypeError(f"--set expects NODE_ID.INPUT=VALUE, got {spec!r}")
48
+ try:
49
+ value = json.loads(raw)
50
+ except json.JSONDecodeError:
51
+ value = raw
52
+ return node_id, input_name, value
53
+
54
+
55
+ def collect_output_files(history_entry):
56
+ """Yield (ref_name, folder_type) for every file any output node produced."""
57
+ for node_output in history_entry.get("outputs", {}).values():
58
+ for value in node_output.values():
59
+ if not isinstance(value, list):
60
+ continue
61
+ for item in value:
62
+ if isinstance(item, dict) and "filename" in item:
63
+ name = item["filename"]
64
+ if item.get("subfolder"):
65
+ name = f"{item['subfolder']}/{name}"
66
+ yield name, item.get("type", "output")
67
+
68
+
69
+ def run(base_url, workflow, timeout=900, poll_interval=2.0, quiet=False):
70
+ """Queue an API-format workflow and block until done. Returns the history entry."""
71
+ base_url = base_url.rstrip("/")
72
+ r = requests.post(
73
+ f"{base_url}/prompt",
74
+ json={"prompt": workflow, "client_id": str(uuid.uuid4())},
75
+ timeout=60,
76
+ )
77
+ if r.status_code != 200:
78
+ try:
79
+ detail = json.dumps(r.json(), indent=2)
80
+ except ValueError:
81
+ detail = r.text
82
+ raise RuntimeError(f"queueing failed (HTTP {r.status_code}):\n{detail}")
83
+ prompt_id = r.json()["prompt_id"]
84
+ if not quiet:
85
+ print(f"queued prompt {prompt_id}", file=sys.stderr)
86
+
87
+ start = time.monotonic()
88
+ while True:
89
+ elapsed = time.monotonic() - start
90
+ if elapsed > timeout:
91
+ raise RuntimeError(f"timed out after {timeout}s waiting for prompt {prompt_id}")
92
+ h = requests.get(f"{base_url}/history/{prompt_id}", timeout=60).json()
93
+ if prompt_id in h:
94
+ entry = h[prompt_id]
95
+ status = entry.get("status", {})
96
+ if status.get("status_str") == "error":
97
+ messages = json.dumps(status.get("messages", []), indent=2)
98
+ raise RuntimeError(f"workflow execution failed:\n{messages}")
99
+ return entry
100
+ if not quiet:
101
+ print(f" waiting... {int(elapsed)}s", file=sys.stderr)
102
+ time.sleep(poll_interval)
103
+
104
+
105
+ def main():
106
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
107
+ ap.add_argument("--url", required=True, help="ComfyUI base URL, e.g. https://POD-7865.proxy.runpod.net")
108
+ ap.add_argument("workflow", help="path to an API-format workflow JSON")
109
+ ap.add_argument("--set", dest="sets", type=parse_set, action="append", default=[],
110
+ metavar="NODE_ID.INPUT=VALUE", help="override a node input (repeatable)")
111
+ ap.add_argument("--prefix", help="set filename_prefix on every node that has one")
112
+ ap.add_argument("--keep-seeds", action="store_true",
113
+ help="keep the seeds saved in the workflow instead of randomizing")
114
+ ap.add_argument("--timeout", type=float, default=900, help="max seconds to wait (default 900)")
115
+ ap.add_argument("--poll", type=float, default=2.0, help="poll interval in seconds (default 2)")
116
+ args = ap.parse_args()
117
+
118
+ workflow = json.loads(pathlib.Path(args.workflow).read_text())
119
+ if "nodes" in workflow and "class_type" not in next(iter(workflow.values()), {}):
120
+ sys.exit("ERROR: this is a UI-format workflow. Convert it first: "
121
+ "python convert_workflow.py --url <URL> <workflow.json>")
122
+
123
+ for node_id, input_name, value in args.sets:
124
+ if node_id not in workflow:
125
+ sys.exit(f"ERROR: --set references unknown node id {node_id!r}")
126
+ workflow[node_id]["inputs"][input_name] = value
127
+ print(f"set {node_id}.{input_name} = {value!r}", file=sys.stderr)
128
+
129
+ if args.prefix:
130
+ for node_id, node in workflow.items():
131
+ if "filename_prefix" in node.get("inputs", {}):
132
+ node["inputs"]["filename_prefix"] = args.prefix
133
+ print(f"set {node_id}.filename_prefix = {args.prefix!r}", file=sys.stderr)
134
+
135
+ if not args.keep_seeds:
136
+ for node_id, node in workflow.items():
137
+ for name in SEED_INPUT_NAMES:
138
+ if isinstance(node.get("inputs", {}).get(name), int):
139
+ node["inputs"][name] = random.randrange(2**48)
140
+ print(f"randomized {node_id}.{name} = {node['inputs'][name]}", file=sys.stderr)
141
+
142
+ try:
143
+ entry = run(args.url, workflow, timeout=args.timeout, poll_interval=args.poll)
144
+ except (requests.RequestException, RuntimeError) as e:
145
+ sys.exit(f"ERROR: {e}")
146
+
147
+ outputs = list(collect_output_files(entry))
148
+ saved = [name for name, ftype in outputs if ftype == "output"]
149
+ temp = [name for name, ftype in outputs if ftype != "output"]
150
+ if temp:
151
+ print(f"({len(temp)} preview/temp file(s) not listed: {', '.join(temp)})", file=sys.stderr)
152
+ if not saved:
153
+ print("workflow finished but produced no saved output files", file=sys.stderr)
154
+ for name in saved:
155
+ print(name)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
workflows/scripts/upload_files.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Upload local files into a ComfyUI instance's input folder.
3
+
4
+ Uses ComfyUI's built-in POST /upload/image endpoint. Prints one reference
5
+ name per uploaded file to stdout ("name.png" or "subfolder/name.png") --
6
+ exactly the string a LoadImage node's `image` input expects.
7
+
8
+ ComfyUI dedups colliding names by renaming to "name (1).png" unless
9
+ --overwrite is given, so ALWAYS use the printed name, not the local one.
10
+
11
+ Examples:
12
+ python upload_files.py --url https://POD-7865.proxy.runpod.net head.jpg body.jpg
13
+ python upload_files.py --url http://127.0.0.1:7865 --subfolder demo --overwrite *.png
14
+ """
15
+ import argparse
16
+ import pathlib
17
+ import sys
18
+
19
+ import requests
20
+
21
+
22
+ def upload_file(base_url, path, subfolder="", overwrite=False, timeout=120):
23
+ """Upload one file; returns the reference name to use in a workflow."""
24
+ path = pathlib.Path(path)
25
+ with path.open("rb") as f:
26
+ r = requests.post(
27
+ f"{base_url.rstrip('/')}/upload/image",
28
+ files={"image": (path.name, f)},
29
+ data={
30
+ "subfolder": subfolder,
31
+ "overwrite": "true" if overwrite else "false",
32
+ "type": "input",
33
+ },
34
+ timeout=timeout,
35
+ )
36
+ r.raise_for_status()
37
+ info = r.json()
38
+ name = info["name"]
39
+ if info.get("subfolder"):
40
+ name = f"{info['subfolder']}/{name}"
41
+ return name
42
+
43
+
44
+ def main():
45
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
46
+ ap.add_argument("--url", required=True, help="ComfyUI base URL, e.g. https://POD-7865.proxy.runpod.net")
47
+ ap.add_argument("--subfolder", default="", help="subfolder under ComfyUI/input/ to upload into")
48
+ ap.add_argument("--overwrite", action="store_true", help="replace existing files instead of auto-renaming")
49
+ ap.add_argument("files", nargs="+", help="local files to upload")
50
+ args = ap.parse_args()
51
+
52
+ failed = False
53
+ for f in args.files:
54
+ try:
55
+ name = upload_file(args.url, f, args.subfolder, args.overwrite)
56
+ except (OSError, requests.RequestException) as e:
57
+ print(f"ERROR uploading {f}: {e}", file=sys.stderr)
58
+ failed = True
59
+ continue
60
+ print(f"uploaded {f} -> input/{name}", file=sys.stderr)
61
+ print(name)
62
+ sys.exit(1 if failed else 0)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()