File size: 6,817 Bytes
dcc0102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61c37a8
 
dcc0102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61c37a8
 
 
dcc0102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61c37a8
 
dcc0102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41a10e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcc0102
 
 
 
 
 
 
 
 
 
 
 
 
 
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
161
#!/usr/bin/env python3
"""Call the qwen-edit-turbo RunPod serverless endpoint SYNCHRONOUSLY (/runsync).

Standalone: python 3.8+, stdlib only. Blocks until the edit is done (typically
40-65 s warm), saves the returned image(s), prints their paths.

    python qwen_edit_sync.py \
        --api-key $RUNPOD_API_KEY \
        --image person.jpg --ref-image outfit.png \
        --prompt "put the outfit from the second image on the person" \
        --mode turbo-8 --out ./results

All arguments:
    --api-key        RunPod API key (or set RUNPOD_API_KEY env var)
    --endpoint-id    RunPod endpoint id       (default: dom5lwr0o5wq6u)
    --image          main input image (required)
    --ref-image      optional reference image (outfit/style source)
    --prompt         edit instruction (required)
    --mode           turbo-4 | turbo-8 | quality        (default turbo-8)
    --seed           integer seed (default: random; used seed is printed)
    --input-max-dim  max size of the main image fed to the model (default 2048)
    --ref-max-dim    max size of the reference image (default 1024)
    --output-max-dim upscale target for the result (default 2560)
    --lora-skin-fix / --lora-qwen4play      enable the optional loras
    --lora-skin-fix-strength / --lora-qwen4play-strength   (default 1.0)
    --workflow       which baked workflow to run (default qwen-edit-turbo-v4)
    --set NODE.INPUT=VALUE   raw graph override, repeatable (advanced)
    --workflow-json FILE     full API-format graph passthrough (advanced)
    --out            output directory (default .)
    --timeout        max seconds to wait (default 600)
"""
import argparse
import base64
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request

DEFAULT_ENDPOINT = "dom5lwr0o5wq6u"


def build_input(args):
    images, params = [], {"prompt": args.prompt, "mode": args.mode}
    images.append({"name": os.path.basename(args.image),
                   "image": base64.b64encode(open(args.image, "rb").read()).decode()})
    if args.ref_image:
        images.append({"name": os.path.basename(args.ref_image),
                       "image": base64.b64encode(open(args.ref_image, "rb").read()).decode()})
    if args.seed is not None:
        params["seed"] = args.seed
    for cli, param in [("input_max_dim", "input_max_dim"), ("ref_max_dim", "ref_max_dim"),
                       ("output_max_dim", "output_max_dim")]:
        v = getattr(args, cli)
        if v is not None:
            params[param] = v
    if args.lora_skin_fix:
        params["lora_skin_fix"] = True
        params["lora_skin_fix_strength"] = args.lora_skin_fix_strength
    if args.lora_qwen4play:
        params["lora_qwen4play"] = True
        params["lora_qwen4play_strength"] = args.lora_qwen4play_strength

    payload = {"images": images, "params": params}
    if args.workflow:
        payload["workflow"] = args.workflow
    if args.workflow_json:
        payload["workflow_json"] = json.load(open(args.workflow_json))
    overrides = {}
    for s in args.set or []:
        k, v = s.split("=", 1)
        try:
            overrides[k] = json.loads(v)
        except json.JSONDecodeError:
            overrides[k] = v
    if overrides:
        payload["set"] = overrides
    return payload


def save_outputs(output, out_dir):
    out_dir = pathlib.Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    saved = []
    for i, img in enumerate(output.get("images", [])):
        p = out_dir / f"{int(time.time())}-{i}-{img['filename']}"
        p.write_bytes(base64.b64decode(img["data"]))
        saved.append(str(p))
    return saved


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--api-key", default=os.environ.get("RUNPOD_API_KEY"))
    ap.add_argument("--endpoint-id", default=DEFAULT_ENDPOINT)
    ap.add_argument("--image", required=True)
    ap.add_argument("--ref-image")
    ap.add_argument("--prompt", required=True)
    ap.add_argument("--mode", default="turbo-8", choices=["turbo-4", "turbo-8", "quality"])
    ap.add_argument("--seed", type=int)
    ap.add_argument("--input-max-dim", type=int)
    ap.add_argument("--ref-max-dim", type=int)
    ap.add_argument("--output-max-dim", type=int)
    ap.add_argument("--lora-skin-fix", action="store_true")
    ap.add_argument("--lora-skin-fix-strength", type=float, default=1.0)
    ap.add_argument("--lora-qwen4play", action="store_true")
    ap.add_argument("--lora-qwen4play-strength", type=float, default=1.0)
    ap.add_argument("--workflow")
    ap.add_argument("--set", action="append", metavar="NODE.INPUT=VALUE")
    ap.add_argument("--workflow-json")
    ap.add_argument("--out", default=".")
    ap.add_argument("--timeout", type=float, default=600)
    args = ap.parse_args()
    if not args.api_key:
        sys.exit("ERROR: pass --api-key or set RUNPOD_API_KEY")

    req = urllib.request.Request(
        f"https://api.runpod.ai/v2/{args.endpoint_id}/runsync",
        data=json.dumps({"input": build_input(args)}).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {args.api_key}"})
    t0 = time.monotonic()
    try:
        with urllib.request.urlopen(req, timeout=args.timeout) as r:
            result = json.load(r)
    except urllib.error.HTTPError as e:
        sys.exit(f"ERROR: HTTP {e.code}: {e.read().decode(errors='replace')[:2000]}")


    # /runsync answers early (IN_QUEUE/IN_PROGRESS) when a job outlives the
    # sync window (~90 s), e.g. during a cold start — fall back to polling
    TERMINAL = ("COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT")
    while result.get("status") not in TERMINAL and result.get("id"):
        if time.monotonic() - t0 > args.timeout:
            sys.exit(f"ERROR: timed out after {args.timeout}s "
                     f"(job {result['id']} status {result.get('status')})")
        time.sleep(3)
        poll = urllib.request.Request(
            f"https://api.runpod.ai/v2/{args.endpoint_id}/status/{result['id']}",
            headers={"Authorization": f"Bearer {args.api_key}"})
        with urllib.request.urlopen(poll, timeout=90) as r:
            result = json.load(r)

    if result.get("status") != "COMPLETED":
        sys.exit(f"ERROR: {json.dumps(result, indent=2)[:3000]}")
    output = result["output"]
    if "error" in output:
        sys.exit(f"ERROR from handler: {output['error']}")
    for p in save_outputs(output, args.out):
        print(p)
    print(f"# seed={output.get('seed')} wall={time.monotonic()-t0:.1f}s "
          f"delay={result.get('delayTime')}ms exec={result.get('executionTime')}ms",
          file=sys.stderr)


if __name__ == "__main__":
    main()