File size: 6,979 Bytes
cfdb90d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/usr/bin/env python3
"""Run an API-format ComfyUI workflow synchronously and wait for it to finish.

Queues the workflow via POST /prompt, polls GET /history/{prompt_id} until the
job completes, then prints one reference name per produced output file to
stdout ("file.png" or "subfolder/file.png") -- feed those straight to
download_files.py. Exits non-zero on validation or execution failure, with
ComfyUI's error details on stderr.

The workflow must be API format (flat {"<id>": {"class_type": ...}} dict).
Export one from the UI with Workflow > Export (API), or convert a saved UI
JSON with convert_workflow.py.

Runtime patching (so workflow files stay generic):
    --set NODE_ID.INPUT=VALUE   override any node input, e.g. --set 76.image=body.jpg
                                (VALUE is parsed as JSON when possible, else kept as string)
    --prefix PREFIX             set filename_prefix on every node that has one,
                                e.g. --prefix "headswap/%year%%month%%day%-%hour%%minute%%second%"
    --randomize-seeds           randomize every seed/noise_seed input (on by default;
                                disable with --keep-seeds for reproducible reruns)

Example:
    python run_workflow.py --url https://POD-7865.proxy.runpod.net \
        ../api/head-swap-flux-klein.json \
        --set 76.image=body.jpg --set 81.image=head.jpg \
        --prefix "headswap/%year%%month%%day%-%hour%%minute%%second%"
"""
import argparse
import json
import pathlib
import random
import sys
import time
import uuid

import requests

SEED_INPUT_NAMES = ("seed", "noise_seed")


def parse_set(spec):
    """'76.image=body.jpg' -> ('76', 'image', 'body.jpg'). Values parse as JSON when possible."""
    try:
        target, raw = spec.split("=", 1)
        node_id, input_name = target.split(".", 1)
    except ValueError:
        raise argparse.ArgumentTypeError(f"--set expects NODE_ID.INPUT=VALUE, got {spec!r}")
    try:
        value = json.loads(raw)
    except json.JSONDecodeError:
        value = raw
    return node_id, input_name, value


def collect_output_files(history_entry):
    """Yield (ref_name, folder_type) for every file any output node produced."""
    for node_output in history_entry.get("outputs", {}).values():
        for value in node_output.values():
            if not isinstance(value, list):
                continue
            for item in value:
                if isinstance(item, dict) and "filename" in item:
                    name = item["filename"]
                    if item.get("subfolder"):
                        name = f"{item['subfolder']}/{name}"
                    yield name, item.get("type", "output")


def run(base_url, workflow, timeout=900, poll_interval=2.0, quiet=False):
    """Queue an API-format workflow and block until done. Returns the history entry."""
    base_url = base_url.rstrip("/")
    r = requests.post(
        f"{base_url}/prompt",
        json={"prompt": workflow, "client_id": str(uuid.uuid4())},
        timeout=60,
    )
    if r.status_code != 200:
        try:
            detail = json.dumps(r.json(), indent=2)
        except ValueError:
            detail = r.text
        raise RuntimeError(f"queueing failed (HTTP {r.status_code}):\n{detail}")
    prompt_id = r.json()["prompt_id"]
    if not quiet:
        print(f"queued prompt {prompt_id}", file=sys.stderr)

    start = time.monotonic()
    while True:
        elapsed = time.monotonic() - start
        if elapsed > timeout:
            raise RuntimeError(f"timed out after {timeout}s waiting for prompt {prompt_id}")
        h = requests.get(f"{base_url}/history/{prompt_id}", timeout=60).json()
        if prompt_id in h:
            entry = h[prompt_id]
            status = entry.get("status", {})
            if status.get("status_str") == "error":
                messages = json.dumps(status.get("messages", []), indent=2)
                raise RuntimeError(f"workflow execution failed:\n{messages}")
            return entry
        if not quiet:
            print(f"  waiting... {int(elapsed)}s", file=sys.stderr)
        time.sleep(poll_interval)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--url", required=True, help="ComfyUI base URL, e.g. https://POD-7865.proxy.runpod.net")
    ap.add_argument("workflow", help="path to an API-format workflow JSON")
    ap.add_argument("--set", dest="sets", type=parse_set, action="append", default=[],
                    metavar="NODE_ID.INPUT=VALUE", help="override a node input (repeatable)")
    ap.add_argument("--prefix", help="set filename_prefix on every node that has one")
    ap.add_argument("--keep-seeds", action="store_true",
                    help="keep the seeds saved in the workflow instead of randomizing")
    ap.add_argument("--timeout", type=float, default=900, help="max seconds to wait (default 900)")
    ap.add_argument("--poll", type=float, default=2.0, help="poll interval in seconds (default 2)")
    args = ap.parse_args()

    workflow = json.loads(pathlib.Path(args.workflow).read_text())
    if "nodes" in workflow and "class_type" not in next(iter(workflow.values()), {}):
        sys.exit("ERROR: this is a UI-format workflow. Convert it first: "
                 "python convert_workflow.py --url <URL> <workflow.json>")

    for node_id, input_name, value in args.sets:
        if node_id not in workflow:
            sys.exit(f"ERROR: --set references unknown node id {node_id!r}")
        workflow[node_id]["inputs"][input_name] = value
        print(f"set {node_id}.{input_name} = {value!r}", file=sys.stderr)

    if args.prefix:
        for node_id, node in workflow.items():
            if "filename_prefix" in node.get("inputs", {}):
                node["inputs"]["filename_prefix"] = args.prefix
                print(f"set {node_id}.filename_prefix = {args.prefix!r}", file=sys.stderr)

    if not args.keep_seeds:
        for node_id, node in workflow.items():
            for name in SEED_INPUT_NAMES:
                if isinstance(node.get("inputs", {}).get(name), int):
                    node["inputs"][name] = random.randrange(2**48)
                    print(f"randomized {node_id}.{name} = {node['inputs'][name]}", file=sys.stderr)

    try:
        entry = run(args.url, workflow, timeout=args.timeout, poll_interval=args.poll)
    except (requests.RequestException, RuntimeError) as e:
        sys.exit(f"ERROR: {e}")

    outputs = list(collect_output_files(entry))
    saved = [name for name, ftype in outputs if ftype == "output"]
    temp = [name for name, ftype in outputs if ftype != "output"]
    if temp:
        print(f"({len(temp)} preview/temp file(s) not listed: {', '.join(temp)})", file=sys.stderr)
    if not saved:
        print("workflow finished but produced no saved output files", file=sys.stderr)
    for name in saved:
        print(name)


if __name__ == "__main__":
    main()