aleph65 commited on
Commit
b83a9c1
·
verified ·
1 Parent(s): 8a19036

serverless: RunPod worker (handler, build/deploy scripts, Dockerfile, qwen-edit-turbo endpoint)

Browse files
serverless/Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reproducible Dockerfile equivalent of build.sh (which assembles the same
2
+ # image daemonlessly with crane, since RunPod pods have no Docker daemon).
3
+ #
4
+ # Build from the HF-repo root (context needs serverless/ and apply_patches.sh),
5
+ # passing your HF token as a BuildKit secret so it is never baked into a layer:
6
+ #
7
+ # DOCKER_BUILDKIT=1 docker build \
8
+ # -f serverless/Dockerfile \
9
+ # --secret id=hf_token,env=HF_TOKEN \
10
+ # --build-arg ENDPOINT=qwen-edit-turbo \
11
+ # -t plx1029/comfyui-serverless:qwen-edit-turbo-v1 .
12
+ #
13
+ # Layer order mirrors build.sh: custom nodes + models first (heavy, rarely
14
+ # change), serverless code last (tiny, changes often) — so code iterations
15
+ # never re-push model layers.
16
+
17
+ FROM plx1029/comfyui-qwen:v6
18
+
19
+ ARG ENDPOINT=qwen-edit-turbo
20
+ ARG CUSTOM_SCRIPTS_COMMIT=main
21
+ ARG QWENEDITUTILS_COMMIT=main
22
+
23
+ # ---- custom nodes (rgthree already ships in the base) ----
24
+ RUN git clone https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git \
25
+ /workspace/ComfyUI/custom_nodes/ComfyUI-Custom-Scripts && \
26
+ git -C /workspace/ComfyUI/custom_nodes/ComfyUI-Custom-Scripts checkout ${CUSTOM_SCRIPTS_COMMIT} && \
27
+ git clone https://github.com/lrzjason/Comfyui-QwenEditUtils.git \
28
+ /workspace/ComfyUI/custom_nodes/Comfyui-QwenEditUtils && \
29
+ git -C /workspace/ComfyUI/custom_nodes/Comfyui-QwenEditUtils checkout ${QWENEDITUTILS_COMMIT}
30
+
31
+ # ---- models: one RUN (= one layer) per file, list must match endpoint.json ----
32
+ COPY serverless/endpoints/${ENDPOINT}/endpoint.json /tmp/endpoint.json
33
+ RUN pip install -q -U huggingface_hub
34
+ # hf download reads the token from the mounted secret; one layer per model
35
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
36
+ hf download aleph65/ComfyUI models/diffusion_models/qwen_image_edit_2511_bf16.safetensors --local-dir /workspace/ComfyUI
37
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
38
+ hf download aleph65/ComfyUI models/text_encoders/qwen_2.5_vl_7b.safetensors --local-dir /workspace/ComfyUI
39
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
40
+ hf download aleph65/ComfyUI models/vae/qwen_image_vae.safetensors --local-dir /workspace/ComfyUI
41
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
42
+ hf download aleph65/ComfyUI models/loras/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors --local-dir /workspace/ComfyUI
43
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
44
+ hf download aleph65/ComfyUI models/loras/Qwen-Image-Edit-2511-Lightning-8steps-V1.0-bf16.safetensors --local-dir /workspace/ComfyUI
45
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
46
+ hf download aleph65/ComfyUI models/loras/Qwen_LoRA_Skin_Fix_v2.safetensors --local-dir /workspace/ComfyUI
47
+ RUN --mount=type=secret,id=hf_token HF_TOKEN=$(cat /run/secrets/hf_token) \
48
+ hf download aleph65/ComfyUI models/loras/Qwen_LoRA_Amateur_Photo_v1.safetensors --local-dir /workspace/ComfyUI
49
+
50
+ # ---- serverless code (top layer: cheap to rebuild) ----
51
+ COPY apply_patches.sh /workspace/apply_patches.sh
52
+ RUN bash /workspace/apply_patches.sh
53
+ COPY serverless/ /opt/serverless/src/
54
+ RUN rm -rf /opt/serverless/src/Dockerfile /opt/serverless/src/build.sh && \
55
+ find /opt/serverless/src/endpoints -mindepth 1 -maxdepth 1 ! -name "${ENDPOINT}" -exec rm -rf {} + && \
56
+ pip install -q --target /opt/serverless/lib runpod && \
57
+ chmod +x /opt/serverless/src/start.sh
58
+
59
+ CMD ["/opt/serverless/src/start.sh"]
serverless/README.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ComfyUI serverless workers (RunPod)
2
+
3
+ Run ComfyUI workflows as RunPod serverless endpoints, optimized for cold start:
4
+ **everything is baked into the image** (models included) so a worker whose host
5
+ has the image cached starts in seconds and never touches the network.
6
+
7
+ Images: `plx1029/comfyui-serverless` on Docker Hub. Current endpoint:
8
+
9
+ | endpoint | image tag | RunPod endpoint id | contents |
10
+ |---|---|---|---|
11
+ | qwen-edit-turbo | `qwen-edit-turbo-v1` | `dom5lwr0o5wq6u` | Qwen-Image-Edit-2511 **bf16** + Lightning 4/8-step loras + Skin-Fix/Amateur loras, workflow `qwen-edit-turbo-v4` |
12
+
13
+ RunPod template: `serverless-qwen-edit-turbo-v1` (`c7i24kc1wh`), endpoint
14
+ `serverless-qwen-edit-turbo`: H100 80GB (SXM/PCIe/NVL), FlashBoot, min 0 /
15
+ max 2 workers, idle timeout 60 s, QUEUE_DELAY scaler.
16
+
17
+ ## Layout
18
+
19
+ ```
20
+ serverless/
21
+ ├── handler.py # generic RunPod handler (same file for every endpoint)
22
+ ├── start.sh # container CMD: boot ComfyUI headless, run handler
23
+ ├── build.sh # daemonless image builder (crane; runs on a pod)
24
+ ├── Dockerfile # reproducible equivalent for machines with Docker
25
+ ├── endpoints/
26
+ │ └── qwen-edit-turbo/
27
+ │ ├── endpoint.json # models + custom nodes + default workflow
28
+ │ ├── workflows/<name>.json # API-format workflow(s) baked into the image
29
+ │ └── params/<name>.json # friendly-param -> node.input mapping
30
+ └── tests/
31
+ ├── local_test.py # drive handler against a locally running ComfyUI
32
+ └── endpoint_test.py # drive a deployed RunPod endpoint
33
+ ```
34
+
35
+ ## Calling the endpoint
36
+
37
+ ```
38
+ POST https://api.runpod.ai/v2/<ENDPOINT_ID>/runsync (or /run + /status/<id>)
39
+ ```
40
+
41
+ ```json
42
+ {"input": {
43
+ "images": [
44
+ {"name": "main.jpg", "image": "<base64 or data-URI>"},
45
+ {"name": "ref.png", "image": "<base64>"}
46
+ ],
47
+ "params": {
48
+ "prompt": "replace the outfit ...",
49
+ "mode": "turbo-8",
50
+ "seed": 123,
51
+ "input_max_dim": 2048,
52
+ "output_max_dim": 2560,
53
+ "lora_skin_fix": true
54
+ },
55
+ "set": {"43.cfg": 1.0}
56
+ }}
57
+ ```
58
+
59
+ - `images[0]` auto-fills the `image` slot, `images[1]` the `image_ref` slot; a
60
+ missing second image automatically sets `use_ref=false`.
61
+ - `mode`: `turbo-4` | `turbo-8` (default) | `quality`.
62
+ - `seed` omitted → randomized (returned in the response).
63
+ - `set` patches any raw `NODE.INPUT` (or `NODE.INPUT.KEY`) after params.
64
+ - `workflow` selects among baked workflows; `workflow_json` runs a full
65
+ API-format graph passthrough.
66
+ - Full param list per workflow: `endpoints/<ep>/params/<workflow>.json`.
67
+
68
+ Response: `{"images": [{"filename", "type": "base64", "data"}], "seed",
69
+ "prompt_id", "workflow", "timings": {...}}`.
70
+
71
+ Payload limits (RunPod): ~10 MB on `/run`, ~20 MB on `/runsync` — enough for
72
+ two photos. URL inputs / S3 outputs are a planned follow-up for bigger traffic.
73
+
74
+ ## Building images (no Docker needed — run on a pod)
75
+
76
+ ```bash
77
+ cd serverless
78
+ # heavy tag: base + custom nodes + one layer PER model (build rarely)
79
+ ./build.sh models qwen-edit-turbo v1
80
+ # tiny tag: + handler/workflows/params + CMD (build often, pushes only MBs)
81
+ ./build.sh code qwen-edit-turbo v1 v1
82
+ ```
83
+
84
+ Needs: `crane` + `pigz` installed, models on disk under
85
+ `$COMFY_WORKSPACE/ComfyUI/models/`, `DOCKER_HUB_ACCESS_TOKEN` set. The model
86
+ layers are content-addressed: rebuilding/pushing a new code version never
87
+ re-uploads them. On a machine with Docker, `Dockerfile` builds the same image.
88
+
89
+ **Adding a new endpoint family** (e.g. krea2): create
90
+ `endpoints/krea2/{endpoint.json,workflows/,params/}`, download its models on
91
+ the build pod (`download_missing_models.sh <workflows...>`), then
92
+ `./build.sh all krea2 v1`. The handler is already generic.
93
+
94
+ ## Cold start design
95
+
96
+ - Models live in image layers → cached on RunPod host NVMe after first pull;
97
+ no network volume, no downloads at start.
98
+ - ComfyUI runs headless (`127.0.0.1`, no Manager/frontend extras) with
99
+ `--highvram` so the ~55 GB of bf16 weights stay resident in VRAM between
100
+ jobs; only the first job on a fresh worker pays model loading.
101
+ - FlashBoot on the endpoint snapshots warm workers.
102
+ - `SERVERLESS_REFRESH=true` (endpoint env) re-pulls handler/workflows/params
103
+ from `hf.co/aleph65/ComfyUI/serverless/` at worker boot — iterate on code and
104
+ workflow JSONs with **zero image rebuild** (off by default to keep cold
105
+ starts fully offline).
106
+
107
+ ## Endpoint env vars
108
+
109
+ | var | default | meaning |
110
+ |---|---|---|
111
+ | `SERVERLESS_ENDPOINT` | sole dir in `endpoints/` | which endpoint config to serve |
112
+ | `COMFY_PORT` | 7865 | internal ComfyUI port |
113
+ | `COMFY_ARGS` | `--highvram` | extra ComfyUI flags |
114
+ | `COMFY_EXEC_TIMEOUT` | 600 | per-job execution timeout (s) |
115
+ | `SERVERLESS_REFRESH` | false | boot-time code refresh from the HF repo |
serverless/build.sh ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Daemonless image builder for RunPod serverless endpoints (crane-based; no
3
+ # Docker needed — runs on any RunPod pod that has the models on disk).
4
+ #
5
+ # Layer model (order matters — cheap-to-change stuff goes LAST):
6
+ #
7
+ # plx1029/comfyui-qwen:v6 (base: torch + ComfyUI + rgthree)
8
+ # + custom-nodes layer + one layer PER model -> :<endpoint>-models-<V> (heavy, build rarely)
9
+ # + serverless code layer + CMD mutate -> :<endpoint>-<V> (tiny, build often)
10
+ #
11
+ # Because the code layer sits on top, iterating on handler/workflows/params
12
+ # re-uploads only a few MB; the model layers are content-addressed and are
13
+ # never pushed twice.
14
+ #
15
+ # Usage (from the directory holding this script):
16
+ # ./build.sh models qwen-edit-turbo v1 # build+push heavy models tag
17
+ # ./build.sh code qwen-edit-turbo v1 v1 # build+push final tag on models-v1
18
+ # ./build.sh all qwen-edit-turbo v1 # both
19
+ #
20
+ # Env: DOCKER_HUB_ACCESS_TOKEN (required), DOCKER_HUB_USER (default plx1029),
21
+ # BASE_IMAGE (default plx1029/comfyui-qwen:v6),
22
+ # DEST_REPO (default docker.io/<user>/comfyui-serverless),
23
+ # COMFY_WORKSPACE (default /workspace).
24
+
25
+ set -euo pipefail
26
+
27
+ CMD_MODE="${1:?models|code|all}"; ENDPOINT="${2:?endpoint name}"; VER="${3:?version}"
28
+ MODELS_VER="${4:-$VER}"
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ WS="${COMFY_WORKSPACE:-/workspace}"
31
+ USER="${DOCKER_HUB_USER:-plx1029}"
32
+ BASE="${BASE_IMAGE:-plx1029/comfyui-qwen:v6}"
33
+ REPO="${DEST_REPO:-docker.io/${USER}/comfyui-serverless}"
34
+ EP_DIR="$SCRIPT_DIR/endpoints/$ENDPOINT"
35
+ BUILD_DIR="${BUILD_DIR:-/root/build}"
36
+ THREADS=$(( $(nproc) > 64 ? 64 : $(nproc) ))
37
+
38
+ [ -f "$EP_DIR/endpoint.json" ] || { echo "no endpoint.json in $EP_DIR"; exit 1; }
39
+ command -v crane >/dev/null || { echo "crane not installed"; exit 1; }
40
+ command -v pigz >/dev/null || { echo "pigz not installed"; exit 1; }
41
+
42
+ echo "$DOCKER_HUB_ACCESS_TOKEN" | crane auth login index.docker.io -u "$USER" --password-stdin
43
+
44
+ mkdir -p "$BUILD_DIR"
45
+ TAR_OPTS=(--owner=0 --group=0 --exclude='__pycache__' --exclude='*.pyc')
46
+
47
+ layer_from_paths() { # $1 = output tgz, rest = paths relative to /
48
+ local out="$1"; shift
49
+ [ -s "$out" ] && { echo " (cached) $out"; return; }
50
+ tar "${TAR_OPTS[@]}" -C / -cf - "$@" | pigz -p "$THREADS" -1 > "$out.tmp"
51
+ mv "$out.tmp" "$out"
52
+ echo " $(du -h "$out" | cut -f1) $out"
53
+ }
54
+
55
+ build_models() {
56
+ local tag="$REPO:$ENDPOINT-models-$VER"
57
+ echo "== building $tag on $BASE"
58
+ local layers=()
59
+
60
+ # custom nodes the endpoint needs that aren't already in the base image
61
+ # (BASE_HAS_NODES lists packs baked into $BASE; rgthree ships in v6)
62
+ local base_has=" ${BASE_HAS_NODES:-rgthree-comfy} "
63
+ local nodes n paths=()
64
+ nodes=$(python3 -c "import json;print(' '.join(json.load(open('$EP_DIR/endpoint.json'))['custom_nodes']))")
65
+ for n in $nodes; do
66
+ case "$base_has" in *" $n "*) continue ;; esac
67
+ [ -d "$WS/ComfyUI/custom_nodes/$n" ] || { echo "missing custom node $n"; exit 1; }
68
+ paths+=("workspace/ComfyUI/custom_nodes/$n")
69
+ done
70
+ if [ "${#paths[@]}" -gt 0 ]; then
71
+ echo "-- custom nodes layer: ${paths[*]}"
72
+ layer_from_paths "$BUILD_DIR/$ENDPOINT-nodes.tgz" "${paths[@]}"
73
+ layers+=(-f "$BUILD_DIR/$ENDPOINT-nodes.tgz")
74
+ fi
75
+
76
+ # one layer per model file (content-addressed => pushed exactly once, ever)
77
+ while read -r m; do
78
+ [ -f "$WS/ComfyUI/$m" ] || { echo "missing model $WS/ComfyUI/$m — download it first"; exit 1; }
79
+ local tgz="$BUILD_DIR/model-$(basename "$m").tgz"
80
+ echo "-- model layer: $m"
81
+ layer_from_paths "$tgz" "workspace/ComfyUI/$m"
82
+ layers+=(-f "$tgz")
83
+ done < <(python3 -c "import json;print('\n'.join(json.load(open('$EP_DIR/endpoint.json'))['models']))")
84
+
85
+ crane append -b "$BASE" "${layers[@]}" -t "$tag"
86
+ echo "== pushed $tag"
87
+ }
88
+
89
+ build_code() {
90
+ local models_tag="$REPO:$ENDPOINT-models-$MODELS_VER"
91
+ local tag="$REPO:$ENDPOINT-$VER"
92
+ echo "== building $tag on $models_tag"
93
+
94
+ local stage="$BUILD_DIR/stage-code"
95
+ rm -rf "$stage"
96
+ mkdir -p "$stage/opt/serverless/src" "$stage/opt/serverless/lib" \
97
+ "$stage/workspace/ComfyUI/comfy"
98
+ cp -r "$SCRIPT_DIR"/handler.py "$SCRIPT_DIR"/start.sh "$SCRIPT_DIR"/endpoints \
99
+ "$SCRIPT_DIR"/tests "$stage/opt/serverless/src/"
100
+ # only THIS endpoint's config goes into the image
101
+ find "$stage/opt/serverless/src/endpoints" -mindepth 1 -maxdepth 1 \
102
+ ! -name "$ENDPOINT" -exec rm -rf {} +
103
+ # vendored python deps for the handler (runpod sdk); build them if absent
104
+ if [ ! -d /opt/serverless/lib/runpod ]; then
105
+ pip install -q --target /opt/serverless/lib runpod
106
+ fi
107
+ cp -r /opt/serverless/lib/. "$stage/opt/serverless/lib/"
108
+ # local core patches (ComfyUI #14573 aimdo fix) ride along with the code
109
+ cp "$WS/ComfyUI/comfy/model_management.py" "$stage/workspace/ComfyUI/comfy/"
110
+ chmod +x "$stage/opt/serverless/src/start.sh"
111
+
112
+ tar "${TAR_OPTS[@]}" --exclude='local-out-*' -C "$stage" -cf - . \
113
+ | pigz -p "$THREADS" -1 > "$BUILD_DIR/code.tgz"
114
+ crane append -b "$models_tag" -f "$BUILD_DIR/code.tgz" -t "$tag"
115
+ crane mutate "$tag" --cmd "/opt/serverless/src/start.sh" -t "$tag"
116
+ echo "== pushed $tag"
117
+ }
118
+
119
+ case "$CMD_MODE" in
120
+ models) build_models ;;
121
+ code) build_code ;;
122
+ all) build_models; MODELS_VER="$VER" build_code ;;
123
+ *) echo "unknown mode $CMD_MODE"; exit 1 ;;
124
+ esac
serverless/deploy.sh ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Create (or update) the RunPod template + serverless endpoint for an endpoint
3
+ # image built by build.sh. Idempotent-ish: reuses template/endpoint by name.
4
+ #
5
+ # ./deploy.sh qwen-edit-turbo v1
6
+ #
7
+ # Env: RUNPOD_API_KEY (required), DOCKER_HUB_USER (default plx1029),
8
+ # GPU_IDS (comma list, default all H100 80GB variants),
9
+ # WORKERS_MAX (default 2), IDLE_TIMEOUT (default 60s),
10
+ # CONTAINER_DISK_GB (default 20).
11
+
12
+ set -euo pipefail
13
+ ENDPOINT="${1:?endpoint name}"; VER="${2:?version}"
14
+ USER="${DOCKER_HUB_USER:-plx1029}"
15
+ IMAGE="docker.io/${USER}/comfyui-serverless:${ENDPOINT}-${VER}"
16
+ TPL_NAME="serverless-${ENDPOINT}-${VER}"
17
+ EP_NAME="serverless-${ENDPOINT}"
18
+ GPUS="${GPU_IDS:-NVIDIA H100 80GB HBM3,NVIDIA H100 PCIe,NVIDIA H100 NVL}"
19
+ API="https://rest.runpod.io/v1"
20
+ AUTH=(-H "Authorization: Bearer $RUNPOD_API_KEY" -H "Content-Type: application/json")
21
+
22
+ gpu_json=$(python3 -c "import json,sys;print(json.dumps(sys.argv[1].split(',')))" "$GPUS")
23
+
24
+ # ---- template ----
25
+ tpl_id=$(curl -s "${AUTH[@]}" "$API/templates" | python3 -c "
26
+ import json,sys
27
+ for t in json.load(sys.stdin):
28
+ if t.get('name')=='$TPL_NAME': print(t['id']); break")
29
+ if [ -z "$tpl_id" ]; then
30
+ tpl_id=$(curl -s "${AUTH[@]}" -X POST "$API/templates" -d "{
31
+ \"name\": \"$TPL_NAME\",
32
+ \"imageName\": \"$IMAGE\",
33
+ \"isServerless\": true,
34
+ \"containerDiskInGb\": ${CONTAINER_DISK_GB:-20},
35
+ \"env\": {}
36
+ }" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('id') or sys.exit(json.dumps(d)))")
37
+ echo "created template $tpl_id ($TPL_NAME -> $IMAGE)"
38
+ else
39
+ echo "template $tpl_id ($TPL_NAME) already exists"
40
+ fi
41
+
42
+ # ---- endpoint ----
43
+ ep_id=$(curl -s "${AUTH[@]}" "$API/endpoints" | python3 -c "
44
+ import json,sys
45
+ for e in json.load(sys.stdin):
46
+ if e.get('name')=='$EP_NAME': print(e['id']); break")
47
+ if [ -z "$ep_id" ]; then
48
+ ep_id=$(curl -s "${AUTH[@]}" -X POST "$API/endpoints" -d "{
49
+ \"name\": \"$EP_NAME\",
50
+ \"templateId\": \"$tpl_id\",
51
+ \"computeType\": \"GPU\",
52
+ \"gpuTypeIds\": $gpu_json,
53
+ \"gpuCount\": 1,
54
+ \"workersMin\": 0,
55
+ \"workersMax\": ${WORKERS_MAX:-2},
56
+ \"idleTimeout\": ${IDLE_TIMEOUT:-60},
57
+ \"flashboot\": true,
58
+ \"scalerType\": \"QUEUE_DELAY\",
59
+ \"scalerValue\": 4,
60
+ \"executionTimeoutMs\": 600000
61
+ }" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('id') or sys.exit(json.dumps(d)))")
62
+ echo "created endpoint $ep_id ($EP_NAME)"
63
+ else
64
+ echo "endpoint $ep_id ($EP_NAME) exists — pointing it at template $tpl_id"
65
+ curl -s "${AUTH[@]}" -X PATCH "$API/endpoints/$ep_id" -d "{\"templateId\": \"$tpl_id\"}" >/dev/null
66
+ fi
67
+ echo
68
+ echo "endpoint id: $ep_id"
69
+ echo " POST https://api.runpod.ai/v2/$ep_id/runsync"
serverless/endpoints/qwen-edit-turbo/endpoint.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "qwen-edit-turbo",
3
+ "description": "Qwen-Image-Edit-2511 bf16 + Lightning 4/8-step turbo (qwen-edit-turbo-v4)",
4
+ "default_workflow": "qwen-edit-turbo-v4",
5
+ "hf_repo": "aleph65/ComfyUI",
6
+ "models": [
7
+ "models/diffusion_models/qwen_image_edit_2511_bf16.safetensors",
8
+ "models/text_encoders/qwen_2.5_vl_7b.safetensors",
9
+ "models/vae/qwen_image_vae.safetensors",
10
+ "models/loras/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
11
+ "models/loras/Qwen-Image-Edit-2511-Lightning-8steps-V1.0-bf16.safetensors",
12
+ "models/loras/Qwen_LoRA_Skin_Fix_v2.safetensors",
13
+ "models/loras/Qwen_LoRA_Amateur_Photo_v1.safetensors"
14
+ ],
15
+ "custom_nodes": [
16
+ "rgthree-comfy",
17
+ "ComfyUI-Custom-Scripts",
18
+ "Comfyui-QwenEditUtils"
19
+ ],
20
+ "comfy_args": "--highvram"
21
+ }
serverless/endpoints/qwen-edit-turbo/params/qwen-edit-turbo-v4.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "params": {
3
+ "prompt": {"node": "40", "input": "prompt"},
4
+ "image": {"node": "20", "input": "image", "type": "image"},
5
+ "image_ref": {"node": "30", "input": "image", "type": "image"},
6
+ "use_ref": {"node": "56", "input": "value"},
7
+ "mode": {"node": "5", "input": "value",
8
+ "choices": {"turbo-4": 1, "turbo-8": 2, "quality": 3}},
9
+ "seed": {"node": "43", "input": "seed"},
10
+ "input_max_dim": {"node": "21", "input": "value"},
11
+ "ref_max_dim": {"node": "31", "input": "value"},
12
+ "upscale_input": {"node": "15", "input": "value"},
13
+ "downscale_input": {"node": "16", "input": "value"},
14
+ "upscale_output": {"node": "46", "input": "value"},
15
+ "output_max_dim": {"node": "47", "input": "value"},
16
+ "lora_skin_fix": {"node": "52", "input": "lora_1", "subkey": "on"},
17
+ "lora_skin_fix_strength": {"node": "52", "input": "lora_1", "subkey": "strength"},
18
+ "lora_amateur": {"node": "52", "input": "lora_2", "subkey": "on"},
19
+ "lora_amateur_strength": {"node": "52", "input": "lora_2", "subkey": "strength"}
20
+ },
21
+ "defaults": {"mode": "turbo-8"},
22
+ "image_slots": ["image", "image_ref"],
23
+ "when_image_absent": {"image_ref": {"use_ref": false}}
24
+ }
serverless/endpoints/qwen-edit-turbo/workflows/qwen-edit-turbo-v4.json ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "UNETLoader",
4
+ "inputs": {
5
+ "unet_name": "qwen_image_edit_2511_bf16.safetensors",
6
+ "weight_dtype": "default"
7
+ }
8
+ },
9
+ "13": {
10
+ "class_type": "ModelSamplingAuraFlow",
11
+ "inputs": {
12
+ "model": [
13
+ "53:6",
14
+ 0
15
+ ],
16
+ "shift": 3.1
17
+ }
18
+ },
19
+ "14": {
20
+ "class_type": "CFGNorm",
21
+ "inputs": {
22
+ "model": [
23
+ "13",
24
+ 0
25
+ ],
26
+ "pre_cfg": false,
27
+ "strength": 1
28
+ }
29
+ },
30
+ "15": {
31
+ "_meta": {
32
+ "title": "upscale_input_to_max_size"
33
+ },
34
+ "class_type": "PrimitiveBoolean",
35
+ "inputs": {
36
+ "value": true
37
+ }
38
+ },
39
+ "16": {
40
+ "_meta": {
41
+ "title": "downscale_input_to_max_size"
42
+ },
43
+ "class_type": "PrimitiveBoolean",
44
+ "inputs": {
45
+ "value": false
46
+ }
47
+ },
48
+ "20": {
49
+ "_meta": {
50
+ "title": "Main Image"
51
+ },
52
+ "class_type": "LoadImage",
53
+ "inputs": {
54
+ "image": "733427737_18059858666742129_6215894921112236726_n.jpg"
55
+ }
56
+ },
57
+ "21": {
58
+ "_meta": {
59
+ "title": "Input max size (main)"
60
+ },
61
+ "class_type": "PrimitiveInt",
62
+ "inputs": {
63
+ "value": 2048
64
+ }
65
+ },
66
+ "27": {
67
+ "_meta": {
68
+ "title": "Main image config"
69
+ },
70
+ "class_type": "QwenEditConfigPreparer",
71
+ "inputs": {
72
+ "image": [
73
+ "20",
74
+ 0
75
+ ],
76
+ "ref_crop": "pad",
77
+ "ref_longest_edge": [
78
+ "54:5",
79
+ 0
80
+ ],
81
+ "ref_main_image": true,
82
+ "ref_upscale": "lanczos",
83
+ "to_ref": true,
84
+ "to_vl": true,
85
+ "vl_crop": "center",
86
+ "vl_resize": true,
87
+ "vl_target_size": 384,
88
+ "vl_upscale": "bicubic"
89
+ }
90
+ },
91
+ "3": {
92
+ "class_type": "CLIPLoader",
93
+ "inputs": {
94
+ "clip_name": "qwen_2.5_vl_7b.safetensors",
95
+ "device": "default",
96
+ "type": "qwen_image"
97
+ }
98
+ },
99
+ "30": {
100
+ "_meta": {
101
+ "title": "Reference Image 2 (optional)"
102
+ },
103
+ "class_type": "LoadImage",
104
+ "inputs": {
105
+ "image": "b.png"
106
+ }
107
+ },
108
+ "31": {
109
+ "_meta": {
110
+ "title": "Input max size (ref 2)"
111
+ },
112
+ "class_type": "PrimitiveInt",
113
+ "inputs": {
114
+ "value": 1024
115
+ }
116
+ },
117
+ "37": {
118
+ "_meta": {
119
+ "title": "Reference image 2 config"
120
+ },
121
+ "class_type": "QwenEditConfigPreparer",
122
+ "inputs": {
123
+ "configs": [
124
+ "27",
125
+ 0
126
+ ],
127
+ "image": [
128
+ "30",
129
+ 0
130
+ ],
131
+ "ref_crop": "center",
132
+ "ref_longest_edge": [
133
+ "55:5",
134
+ 0
135
+ ],
136
+ "ref_main_image": false,
137
+ "ref_upscale": "lanczos",
138
+ "to_ref": true,
139
+ "to_vl": true,
140
+ "vl_crop": "center",
141
+ "vl_resize": true,
142
+ "vl_target_size": 384,
143
+ "vl_upscale": "bicubic"
144
+ }
145
+ },
146
+ "4": {
147
+ "class_type": "VAELoader",
148
+ "inputs": {
149
+ "vae_name": "qwen_image_vae.safetensors"
150
+ }
151
+ },
152
+ "40": {
153
+ "_meta": {
154
+ "title": "Qwen Edit Encode (prompt here)"
155
+ },
156
+ "class_type": "TextEncodeQwenImageEditPlusCustom_lrzjason",
157
+ "inputs": {
158
+ "clip": [
159
+ "3",
160
+ 0
161
+ ],
162
+ "configs": [
163
+ "57",
164
+ 0
165
+ ],
166
+ "instruction": "",
167
+ "prompt": "replace the outfit of the subject in the first image with the outfit in the second image, don't change anything else. do not change the body's anatomy or shape or skin tone. only put the outfit onto the person in the first image. ",
168
+ "return_full_refs_cond": true,
169
+ "vae": [
170
+ "4",
171
+ 0
172
+ ]
173
+ }
174
+ },
175
+ "41": {
176
+ "class_type": "QwenEditOutputExtractor",
177
+ "inputs": {
178
+ "custom_output": [
179
+ "40",
180
+ 2
181
+ ]
182
+ }
183
+ },
184
+ "42": {
185
+ "_meta": {
186
+ "title": "Negative (zeroed)"
187
+ },
188
+ "class_type": "ConditioningZeroOut",
189
+ "inputs": {
190
+ "conditioning": [
191
+ "40",
192
+ 0
193
+ ]
194
+ }
195
+ },
196
+ "43": {
197
+ "class_type": "KSampler",
198
+ "inputs": {
199
+ "cfg": [
200
+ "53:14",
201
+ 0
202
+ ],
203
+ "denoise": 1,
204
+ "latent_image": [
205
+ "40",
206
+ 1
207
+ ],
208
+ "model": [
209
+ "14",
210
+ 0
211
+ ],
212
+ "negative": [
213
+ "42",
214
+ 0
215
+ ],
216
+ "positive": [
217
+ "40",
218
+ 0
219
+ ],
220
+ "sampler_name": "euler",
221
+ "scheduler": "simple",
222
+ "seed": 440984114065874,
223
+ "steps": [
224
+ "53:11",
225
+ 0
226
+ ]
227
+ }
228
+ },
229
+ "44": {
230
+ "class_type": "VAEDecode",
231
+ "inputs": {
232
+ "samples": [
233
+ "43",
234
+ 0
235
+ ],
236
+ "vae": [
237
+ "4",
238
+ 0
239
+ ]
240
+ }
241
+ },
242
+ "45": {
243
+ "_meta": {
244
+ "title": "Remove padding"
245
+ },
246
+ "class_type": "CropWithPadInfo",
247
+ "inputs": {
248
+ "image": [
249
+ "44",
250
+ 0
251
+ ],
252
+ "pad_info": [
253
+ "41",
254
+ 0
255
+ ]
256
+ }
257
+ },
258
+ "46": {
259
+ "_meta": {
260
+ "title": "upscale_output_to_selected_size"
261
+ },
262
+ "class_type": "PrimitiveBoolean",
263
+ "inputs": {
264
+ "value": true
265
+ }
266
+ },
267
+ "47": {
268
+ "_meta": {
269
+ "title": "Selected output size"
270
+ },
271
+ "class_type": "PrimitiveInt",
272
+ "inputs": {
273
+ "value": 2560
274
+ }
275
+ },
276
+ "48": {
277
+ "_meta": {
278
+ "title": "Scale output"
279
+ },
280
+ "class_type": "ImageScaleToMaxDimension",
281
+ "inputs": {
282
+ "image": [
283
+ "45",
284
+ 0
285
+ ],
286
+ "largest_size": [
287
+ "47",
288
+ 0
289
+ ],
290
+ "upscale_method": "lanczos"
291
+ }
292
+ },
293
+ "49": {
294
+ "_meta": {
295
+ "title": "Output size switch"
296
+ },
297
+ "class_type": "ComfySwitchNode",
298
+ "inputs": {
299
+ "on_false": [
300
+ "45",
301
+ 0
302
+ ],
303
+ "on_true": [
304
+ "48",
305
+ 0
306
+ ],
307
+ "switch": [
308
+ "46",
309
+ 0
310
+ ]
311
+ }
312
+ },
313
+ "5": {
314
+ "_meta": {
315
+ "title": "Sampling mode: 1=4-step / 2=8-step / 3=quality"
316
+ },
317
+ "class_type": "PrimitiveInt",
318
+ "inputs": {
319
+ "value": 1
320
+ }
321
+ },
322
+ "50": {
323
+ "class_type": "SaveImage",
324
+ "inputs": {
325
+ "filename_prefix": "qwen-edit-turbo-v4",
326
+ "images": [
327
+ "49",
328
+ 0
329
+ ]
330
+ }
331
+ },
332
+ "52": {
333
+ "_meta": {
334
+ "title": "User LoRAs"
335
+ },
336
+ "class_type": "Power Lora Loader (rgthree)",
337
+ "inputs": {
338
+ "lora_1": {
339
+ "lora": "Qwen_LoRA_Skin_Fix_v2.safetensors",
340
+ "on": false,
341
+ "strength": 1,
342
+ "strengthTwo": null
343
+ },
344
+ "lora_2": {
345
+ "lora": "Qwen_LoRA_Amateur_Photo_v1.safetensors",
346
+ "on": false,
347
+ "strength": 1,
348
+ "strengthTwo": null
349
+ },
350
+ "model": [
351
+ "1",
352
+ 0
353
+ ]
354
+ }
355
+ },
356
+ "53:1": {
357
+ "_meta": {
358
+ "title": "LoRA: Lightning 4 steps"
359
+ },
360
+ "class_type": "LoraLoaderModelOnly",
361
+ "inputs": {
362
+ "lora_name": "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
363
+ "model": [
364
+ "52",
365
+ 0
366
+ ],
367
+ "strength_model": 1
368
+ }
369
+ },
370
+ "53:10": {
371
+ "_meta": {
372
+ "title": "Steps switch 4/8"
373
+ },
374
+ "class_type": "ComfySwitchNode",
375
+ "inputs": {
376
+ "on_false": [
377
+ "53:7",
378
+ 0
379
+ ],
380
+ "on_true": [
381
+ "53:8",
382
+ 0
383
+ ],
384
+ "switch": [
385
+ "53:3",
386
+ 2
387
+ ]
388
+ }
389
+ },
390
+ "53:11": {
391
+ "_meta": {
392
+ "title": "Steps switch quality"
393
+ },
394
+ "class_type": "ComfySwitchNode",
395
+ "inputs": {
396
+ "on_false": [
397
+ "53:10",
398
+ 0
399
+ ],
400
+ "on_true": [
401
+ "53:9",
402
+ 0
403
+ ],
404
+ "switch": [
405
+ "53:4",
406
+ 2
407
+ ]
408
+ }
409
+ },
410
+ "53:12": {
411
+ "_meta": {
412
+ "title": "CFG (lightning)"
413
+ },
414
+ "class_type": "PrimitiveFloat",
415
+ "inputs": {
416
+ "value": 1.0
417
+ }
418
+ },
419
+ "53:13": {
420
+ "_meta": {
421
+ "title": "CFG (quality)"
422
+ },
423
+ "class_type": "PrimitiveFloat",
424
+ "inputs": {
425
+ "value": 3.0
426
+ }
427
+ },
428
+ "53:14": {
429
+ "_meta": {
430
+ "title": "CFG switch"
431
+ },
432
+ "class_type": "ComfySwitchNode",
433
+ "inputs": {
434
+ "on_false": [
435
+ "53:12",
436
+ 0
437
+ ],
438
+ "on_true": [
439
+ "53:13",
440
+ 0
441
+ ],
442
+ "switch": [
443
+ "53:4",
444
+ 2
445
+ ]
446
+ }
447
+ },
448
+ "53:2": {
449
+ "_meta": {
450
+ "title": "LoRA: Lightning 8 steps"
451
+ },
452
+ "class_type": "LoraLoaderModelOnly",
453
+ "inputs": {
454
+ "lora_name": "Qwen-Image-Edit-2511-Lightning-8steps-V1.0-bf16.safetensors",
455
+ "model": [
456
+ "52",
457
+ 0
458
+ ],
459
+ "strength_model": 1
460
+ }
461
+ },
462
+ "53:3": {
463
+ "_meta": {
464
+ "title": "is 8-step (mode == 2)"
465
+ },
466
+ "class_type": "ComfyMathExpression",
467
+ "inputs": {
468
+ "expression": "a == 2",
469
+ "values.a": [
470
+ "5",
471
+ 0
472
+ ]
473
+ }
474
+ },
475
+ "53:4": {
476
+ "_meta": {
477
+ "title": "is quality (mode == 3)"
478
+ },
479
+ "class_type": "ComfyMathExpression",
480
+ "inputs": {
481
+ "expression": "a == 3",
482
+ "values.a": [
483
+ "5",
484
+ 0
485
+ ]
486
+ }
487
+ },
488
+ "53:5": {
489
+ "_meta": {
490
+ "title": "Model switch 4/8"
491
+ },
492
+ "class_type": "ComfySwitchNode",
493
+ "inputs": {
494
+ "on_false": [
495
+ "53:1",
496
+ 0
497
+ ],
498
+ "on_true": [
499
+ "53:2",
500
+ 0
501
+ ],
502
+ "switch": [
503
+ "53:3",
504
+ 2
505
+ ]
506
+ }
507
+ },
508
+ "53:6": {
509
+ "_meta": {
510
+ "title": "Model switch quality"
511
+ },
512
+ "class_type": "ComfySwitchNode",
513
+ "inputs": {
514
+ "on_false": [
515
+ "53:5",
516
+ 0
517
+ ],
518
+ "on_true": [
519
+ "52",
520
+ 0
521
+ ],
522
+ "switch": [
523
+ "53:4",
524
+ 2
525
+ ]
526
+ }
527
+ },
528
+ "53:7": {
529
+ "_meta": {
530
+ "title": "Steps (4-step)"
531
+ },
532
+ "class_type": "PrimitiveInt",
533
+ "inputs": {
534
+ "value": 4
535
+ }
536
+ },
537
+ "53:8": {
538
+ "_meta": {
539
+ "title": "Steps (8-step)"
540
+ },
541
+ "class_type": "PrimitiveInt",
542
+ "inputs": {
543
+ "value": 8
544
+ }
545
+ },
546
+ "53:9": {
547
+ "_meta": {
548
+ "title": "Steps (quality)"
549
+ },
550
+ "class_type": "PrimitiveInt",
551
+ "inputs": {
552
+ "value": 12
553
+ }
554
+ },
555
+ "54:1": {
556
+ "_meta": {
557
+ "title": "native edge"
558
+ },
559
+ "class_type": "MathExpression|pysssss",
560
+ "inputs": {
561
+ "a": [
562
+ "20",
563
+ 0
564
+ ],
565
+ "expression": "max(a.width, a.height)"
566
+ }
567
+ },
568
+ "54:2": {
569
+ "_meta": {
570
+ "title": "upscaled edge"
571
+ },
572
+ "class_type": "MathExpression|pysssss",
573
+ "inputs": {
574
+ "a": [
575
+ "20",
576
+ 0
577
+ ],
578
+ "c": [
579
+ "21",
580
+ 0
581
+ ],
582
+ "expression": "max(a.width, a.height, c)"
583
+ }
584
+ },
585
+ "54:3": {
586
+ "_meta": {
587
+ "title": "apply upscale"
588
+ },
589
+ "class_type": "ComfySwitchNode",
590
+ "inputs": {
591
+ "on_false": [
592
+ "54:1",
593
+ 0
594
+ ],
595
+ "on_true": [
596
+ "54:2",
597
+ 0
598
+ ],
599
+ "switch": [
600
+ "15",
601
+ 0
602
+ ]
603
+ }
604
+ },
605
+ "54:4": {
606
+ "_meta": {
607
+ "title": "downscaled edge"
608
+ },
609
+ "class_type": "MathExpression|pysssss",
610
+ "inputs": {
611
+ "a": [
612
+ "54:3",
613
+ 0
614
+ ],
615
+ "c": [
616
+ "21",
617
+ 0
618
+ ],
619
+ "expression": "min(a, c)"
620
+ }
621
+ },
622
+ "54:5": {
623
+ "_meta": {
624
+ "title": "apply downscale"
625
+ },
626
+ "class_type": "ComfySwitchNode",
627
+ "inputs": {
628
+ "on_false": [
629
+ "54:3",
630
+ 0
631
+ ],
632
+ "on_true": [
633
+ "54:4",
634
+ 0
635
+ ],
636
+ "switch": [
637
+ "16",
638
+ 0
639
+ ]
640
+ }
641
+ },
642
+ "55:1": {
643
+ "_meta": {
644
+ "title": "native edge"
645
+ },
646
+ "class_type": "MathExpression|pysssss",
647
+ "inputs": {
648
+ "a": [
649
+ "30",
650
+ 0
651
+ ],
652
+ "expression": "max(a.width, a.height)"
653
+ }
654
+ },
655
+ "55:2": {
656
+ "_meta": {
657
+ "title": "upscaled edge"
658
+ },
659
+ "class_type": "MathExpression|pysssss",
660
+ "inputs": {
661
+ "a": [
662
+ "30",
663
+ 0
664
+ ],
665
+ "c": [
666
+ "31",
667
+ 0
668
+ ],
669
+ "expression": "max(a.width, a.height, c)"
670
+ }
671
+ },
672
+ "55:3": {
673
+ "_meta": {
674
+ "title": "apply upscale"
675
+ },
676
+ "class_type": "ComfySwitchNode",
677
+ "inputs": {
678
+ "on_false": [
679
+ "55:1",
680
+ 0
681
+ ],
682
+ "on_true": [
683
+ "55:2",
684
+ 0
685
+ ],
686
+ "switch": [
687
+ "15",
688
+ 0
689
+ ]
690
+ }
691
+ },
692
+ "55:4": {
693
+ "_meta": {
694
+ "title": "downscaled edge"
695
+ },
696
+ "class_type": "MathExpression|pysssss",
697
+ "inputs": {
698
+ "a": [
699
+ "55:3",
700
+ 0
701
+ ],
702
+ "c": [
703
+ "31",
704
+ 0
705
+ ],
706
+ "expression": "min(a, c)"
707
+ }
708
+ },
709
+ "55:5": {
710
+ "_meta": {
711
+ "title": "apply downscale"
712
+ },
713
+ "class_type": "ComfySwitchNode",
714
+ "inputs": {
715
+ "on_false": [
716
+ "55:3",
717
+ 0
718
+ ],
719
+ "on_true": [
720
+ "55:4",
721
+ 0
722
+ ],
723
+ "switch": [
724
+ "16",
725
+ 0
726
+ ]
727
+ }
728
+ },
729
+ "56": {
730
+ "_meta": {
731
+ "title": "Use Reference Image 2"
732
+ },
733
+ "class_type": "PrimitiveBoolean",
734
+ "inputs": {
735
+ "value": true
736
+ }
737
+ },
738
+ "57": {
739
+ "_meta": {
740
+ "title": "Ref 2 on/off switch"
741
+ },
742
+ "class_type": "ComfySwitchNode",
743
+ "inputs": {
744
+ "on_false": [
745
+ "27",
746
+ 0
747
+ ],
748
+ "on_true": [
749
+ "37",
750
+ 0
751
+ ],
752
+ "switch": [
753
+ "56",
754
+ 0
755
+ ]
756
+ }
757
+ }
758
+ }
serverless/handler.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RunPod serverless handler for ComfyUI workflows.
2
+
3
+ Generic across endpoint images: everything workflow-specific lives in
4
+ endpoints/<family>/ (API-format workflow JSONs + param maps + endpoint.json).
5
+ The family baked into an image is selected by $SERVERLESS_ENDPOINT
6
+ (default: the only directory in endpoints/).
7
+
8
+ Request schema (everything except images/prompt optional):
9
+
10
+ {"input": {
11
+ "workflow": "qwen-edit-turbo-v4", # default: endpoint.json default_workflow
12
+ "images": [ # written into ComfyUI's input dir
13
+ {"name": "main.jpg", "image": "<base64 or data-URI>"},
14
+ {"name": "ref.png", "image": "<base64>"}
15
+ ],
16
+ "params": { # friendly names from the param map
17
+ "prompt": "...",
18
+ "image": "main.jpg", # default: images[0]
19
+ "image_ref": "ref.png", # default: images[1] if present
20
+ "mode": "turbo-8", # turbo-4 | turbo-8 | quality
21
+ "seed": 123, # default: random
22
+ "input_max_dim": 2048, "output_max_dim": 2560, ...
23
+ },
24
+ "set": {"43.cfg": 1.0}, # raw NODE.INPUT overrides, applied last
25
+ "workflow_json": { ... } # full API-graph passthrough (advanced;
26
+ # params other than images/set are ignored)
27
+ }}
28
+
29
+ Response:
30
+
31
+ {"images": [{"filename": "...", "type": "base64", "data": "..."}],
32
+ "seed": 123, "prompt_id": "...",
33
+ "timings": {"queue_s": ..., "execute_s": ..., "total_s": ...}}
34
+ """
35
+
36
+ import base64
37
+ import binascii
38
+ import copy
39
+ import glob
40
+ import json
41
+ import os
42
+ import random
43
+ import shutil
44
+ import sys
45
+ import time
46
+ import urllib.error
47
+ import urllib.request
48
+
49
+ SERVERLESS_DIR = os.path.dirname(os.path.abspath(__file__))
50
+ COMFY_HOST = f"127.0.0.1:{os.environ.get('COMFY_PORT', '7865')}"
51
+ COMFY_DIR = os.environ.get("COMFY_DIR", "/workspace/ComfyUI")
52
+ INPUT_DIR = os.path.join(COMFY_DIR, "input")
53
+ OUTPUT_DIR = os.path.join(COMFY_DIR, "output")
54
+ BOOT_TIMEOUT = float(os.environ.get("COMFY_BOOT_TIMEOUT", "300"))
55
+ EXEC_TIMEOUT = float(os.environ.get("COMFY_EXEC_TIMEOUT", "600"))
56
+ POLL_INTERVAL = 0.25
57
+ SEED_INPUTS = ("seed", "noise_seed")
58
+
59
+
60
+ # ---------------- endpoint config ----------------
61
+
62
+ def _load_endpoint():
63
+ root = os.path.join(SERVERLESS_DIR, "endpoints")
64
+ name = os.environ.get("SERVERLESS_ENDPOINT")
65
+ if not name:
66
+ dirs = sorted(d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d)))
67
+ if len(dirs) != 1:
68
+ raise RuntimeError(f"set SERVERLESS_ENDPOINT; found {dirs} in {root}")
69
+ name = dirs[0]
70
+ base = os.path.join(root, name)
71
+ with open(os.path.join(base, "endpoint.json")) as fh:
72
+ cfg = json.load(fh)
73
+ workflows, params = {}, {}
74
+ for p in glob.glob(os.path.join(base, "workflows", "*.json")):
75
+ workflows[os.path.splitext(os.path.basename(p))[0]] = p
76
+ for p in glob.glob(os.path.join(base, "params", "*.json")):
77
+ params[os.path.splitext(os.path.basename(p))[0]] = p
78
+ return name, cfg, workflows, params
79
+
80
+
81
+ ENDPOINT_NAME, ENDPOINT_CFG, WORKFLOWS, PARAM_MAPS = _load_endpoint()
82
+
83
+
84
+ # ---------------- comfy http ----------------
85
+
86
+ def _http(method, path, payload=None, timeout=30):
87
+ url = f"http://{COMFY_HOST}{path}"
88
+ data = json.dumps(payload).encode() if payload is not None else None
89
+ req = urllib.request.Request(url, data=data, method=method,
90
+ headers={"Content-Type": "application/json"})
91
+ with urllib.request.urlopen(req, timeout=timeout) as r:
92
+ body = r.read()
93
+ return json.loads(body) if body else {}
94
+
95
+
96
+ def wait_for_comfy(timeout=BOOT_TIMEOUT):
97
+ start = time.monotonic()
98
+ last_err = None
99
+ while time.monotonic() - start < timeout:
100
+ try:
101
+ _http("GET", "/system_stats", timeout=5)
102
+ return time.monotonic() - start
103
+ except Exception as e: # noqa: BLE001 - retry until deadline
104
+ last_err = e
105
+ time.sleep(0.5)
106
+ log = ""
107
+ for lf in ("/comfyui.log", os.path.join(COMFY_DIR, "comfyui.log")):
108
+ if os.path.exists(lf):
109
+ with open(lf) as fh:
110
+ log = "".join(fh.readlines()[-40:])
111
+ break
112
+ raise RuntimeError(f"ComfyUI not up after {timeout:.0f}s ({last_err}); log tail:\n{log}")
113
+
114
+
115
+ # ---------------- request handling ----------------
116
+
117
+ def _decode_image(entry, dest_dir):
118
+ name = os.path.basename(entry.get("name", ""))
119
+ if not name:
120
+ raise ValueError("each images[] entry needs a 'name'")
121
+ data = entry.get("image", "")
122
+ if data.startswith("data:"):
123
+ data = data.split(",", 1)[-1]
124
+ try:
125
+ raw = base64.b64decode(data, validate=True)
126
+ except (binascii.Error, ValueError) as e:
127
+ raise ValueError(f"images[{name!r}] is not valid base64: {e}") from None
128
+ os.makedirs(dest_dir, exist_ok=True)
129
+ with open(os.path.join(dest_dir, name), "wb") as fh:
130
+ fh.write(raw)
131
+ return name
132
+
133
+
134
+ def _apply_param(graph, spec, value):
135
+ node = graph.get(str(spec["node"]))
136
+ if node is None:
137
+ raise ValueError(f"param targets missing node {spec['node']}")
138
+ inputs = node.setdefault("inputs", {})
139
+ if "choices" in spec:
140
+ if str(value) not in spec["choices"]:
141
+ raise ValueError(f"value {value!r} not in {sorted(spec['choices'])}")
142
+ value = spec["choices"][str(value)]
143
+ if "subkey" in spec:
144
+ tgt = inputs.get(spec["input"])
145
+ if not isinstance(tgt, dict):
146
+ raise ValueError(f"node {spec['node']}.{spec['input']} is not a dict input")
147
+ tgt[spec["subkey"]] = value
148
+ else:
149
+ inputs[spec["input"]] = value
150
+
151
+
152
+ def build_graph(job_input, job_id, image_names):
153
+ """Return (graph, seed, workflow_name)."""
154
+ if isinstance(job_input.get("workflow_json"), dict):
155
+ graph = copy.deepcopy(job_input["workflow_json"])
156
+ wf_name = "(passthrough)"
157
+ pmap = {"params": {}, "defaults": {}, "image_slots": []}
158
+ else:
159
+ wf_name = job_input.get("workflow") or ENDPOINT_CFG.get("default_workflow")
160
+ if wf_name not in WORKFLOWS:
161
+ raise ValueError(f"unknown workflow {wf_name!r}; baked: {sorted(WORKFLOWS)}")
162
+ with open(WORKFLOWS[wf_name]) as fh:
163
+ graph = json.load(fh)
164
+ with open(PARAM_MAPS[wf_name]) as fh:
165
+ pmap = json.load(fh)
166
+
167
+ params = dict(pmap.get("defaults", {}))
168
+ params.update(job_input.get("params") or {})
169
+
170
+ # image slots default to uploaded images in order; slots with no upload of
171
+ # their own trigger their "absent" side effects (e.g. use_ref=false) and
172
+ # then get a placeholder image anyway — ComfyUI validates every LoadImage
173
+ # node, even ones behind a disabled switch.
174
+ slots = pmap.get("image_slots", [])
175
+ for i, slot in enumerate(slots):
176
+ if slot not in params and i < len(image_names):
177
+ params[slot] = image_names[i]
178
+ for slot, effects in (pmap.get("when_image_absent") or {}).items():
179
+ if slot not in params:
180
+ for k, v in effects.items():
181
+ params.setdefault(k, v)
182
+ for slot in slots:
183
+ if slot not in params and image_names:
184
+ params[slot] = image_names[0]
185
+
186
+ spec_map = pmap.get("params", {})
187
+ for key, value in params.items():
188
+ spec = spec_map.get(key)
189
+ if spec is None:
190
+ raise ValueError(f"unknown param {key!r}; available: {sorted(spec_map)}")
191
+ if spec.get("type") == "image":
192
+ if value not in image_names:
193
+ raise ValueError(f"param {key!r}={value!r} does not match an uploaded image name")
194
+ value = f"{job_id}/{value}"
195
+ _apply_param(graph, spec, value)
196
+
197
+ # raw overrides, applied last: {"43.cfg": 1.0} or {"52.lora_1.on": true}
198
+ for target, value in (job_input.get("set") or {}).items():
199
+ parts = target.split(".")
200
+ if len(parts) == 2:
201
+ spec = {"node": parts[0], "input": parts[1]}
202
+ elif len(parts) == 3:
203
+ spec = {"node": parts[0], "input": parts[1], "subkey": parts[2]}
204
+ else:
205
+ raise ValueError(f"bad set target {target!r} (want NODE.INPUT or NODE.INPUT.KEY)")
206
+ _apply_param(graph, spec, value)
207
+
208
+ # seed: explicit param wins; otherwise randomize every seed-ish input
209
+ seed = params.get("seed")
210
+ if seed is None:
211
+ seed = random.randrange(2**48)
212
+ for node in graph.values():
213
+ for name in SEED_INPUTS:
214
+ if isinstance(node.get("inputs", {}).get(name), int):
215
+ node["inputs"][name] = seed
216
+
217
+ # route outputs into a per-job folder for clean collection
218
+ for node in graph.values():
219
+ if "filename_prefix" in node.get("inputs", {}):
220
+ node["inputs"]["filename_prefix"] = f"{job_id}/out"
221
+
222
+ return graph, seed, wf_name
223
+
224
+
225
+ def run_graph(graph):
226
+ """Queue and wait. Returns (history_entry, queue_s, execute_s)."""
227
+ t0 = time.monotonic()
228
+ try:
229
+ resp = _http("POST", "/prompt", {"prompt": graph, "client_id": "runpod"})
230
+ except urllib.error.HTTPError as e:
231
+ detail = e.read().decode(errors="replace")
232
+ try:
233
+ detail = json.dumps(json.loads(detail), indent=2)
234
+ except ValueError:
235
+ pass
236
+ raise RuntimeError(f"workflow validation failed (HTTP {e.code}):\n{detail}") from None
237
+ prompt_id = resp["prompt_id"]
238
+ queued = time.monotonic() - t0
239
+
240
+ while True:
241
+ if time.monotonic() - t0 > EXEC_TIMEOUT:
242
+ _http("POST", "/interrupt", {})
243
+ raise RuntimeError(f"execution timed out after {EXEC_TIMEOUT:.0f}s")
244
+ h = _http("GET", f"/history/{prompt_id}")
245
+ if prompt_id in h:
246
+ entry = h[prompt_id]
247
+ status = entry.get("status", {})
248
+ if status.get("status_str") == "error":
249
+ msgs = [m for m in status.get("messages", []) if m and m[0] == "execution_error"]
250
+ raise RuntimeError("workflow execution failed:\n"
251
+ + json.dumps(msgs or status.get("messages", []), indent=2))
252
+ return entry, prompt_id, queued, time.monotonic() - t0
253
+ time.sleep(POLL_INTERVAL)
254
+
255
+
256
+ def collect_outputs(entry):
257
+ out = []
258
+ for node_output in entry.get("outputs", {}).values():
259
+ for value in node_output.values():
260
+ if not isinstance(value, list):
261
+ continue
262
+ for item in value:
263
+ if isinstance(item, dict) and "filename" in item and item.get("type") == "output":
264
+ sub = item.get("subfolder", "")
265
+ path = os.path.join(OUTPUT_DIR, sub, item["filename"])
266
+ with open(path, "rb") as fh:
267
+ out.append({"filename": item["filename"], "type": "base64",
268
+ "data": base64.b64encode(fh.read()).decode()})
269
+ return out
270
+
271
+
272
+ def handler(job):
273
+ t_start = time.monotonic()
274
+ job_id = job.get("id") or f"job-{random.randrange(16**8):08x}"
275
+ job_id = job_id.replace("/", "_")
276
+ job_input = job.get("input") or {}
277
+ job_input_dir = os.path.join(INPUT_DIR, job_id)
278
+ try:
279
+ image_names = [_decode_image(e, job_input_dir)
280
+ for e in (job_input.get("images") or [])]
281
+ graph, seed, wf_name = build_graph(job_input, job_id, image_names)
282
+ entry, prompt_id, queue_s, execute_s = run_graph(graph)
283
+ images = collect_outputs(entry)
284
+ if not images:
285
+ return {"error": "workflow finished but produced no output images"}
286
+ return {
287
+ "images": images,
288
+ "seed": seed,
289
+ "workflow": wf_name,
290
+ "prompt_id": prompt_id,
291
+ "timings": {"queue_s": round(queue_s, 3),
292
+ "execute_s": round(execute_s, 3),
293
+ "total_s": round(time.monotonic() - t_start, 3)},
294
+ }
295
+ except (ValueError, RuntimeError) as e:
296
+ return {"error": str(e)}
297
+ finally:
298
+ shutil.rmtree(job_input_dir, ignore_errors=True)
299
+ shutil.rmtree(os.path.join(OUTPUT_DIR, job_id), ignore_errors=True)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ boot_wait = wait_for_comfy()
304
+ print(f"[serverless] endpoint={ENDPOINT_NAME} workflows={sorted(WORKFLOWS)} "
305
+ f"comfy ready after {boot_wait:.1f}s", flush=True)
306
+ import runpod
307
+ runpod.serverless.start({"handler": handler})
serverless/start.sh ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Container CMD for the serverless image: boot ComfyUI headless, then run the
3
+ # RunPod handler. Models + code are baked; nothing is fetched at cold start
4
+ # unless SERVERLESS_REFRESH=true (then handler/workflows/params are re-pulled
5
+ # from the HF repo best-effort, for zero-rebuild iteration).
6
+ set -u
7
+
8
+ COMFY_DIR="${COMFY_DIR:-/workspace/ComfyUI}"
9
+ COMFY_PORT="${COMFY_PORT:-7865}"
10
+ SRC="${SERVERLESS_SRC:-/opt/serverless/src}"
11
+ export PYTHONPATH="/opt/serverless/lib${PYTHONPATH:+:$PYTHONPATH}"
12
+
13
+ if [ "${SERVERLESS_REFRESH:-false}" = "true" ]; then
14
+ echo "[start] refreshing serverless code from HF repo (SERVERLESS_REFRESH=true)"
15
+ tok="${HUGGING_FACE_ACCESS_TOKEN:-${HF_TOKEN:-}}"
16
+ auth=(); [ -n "$tok" ] && auth=(-H "Authorization: Bearer $tok")
17
+ base="https://huggingface.co/aleph65/ComfyUI/resolve/main/serverless"
18
+ while read -r rel; do
19
+ tmp=$(mktemp)
20
+ if curl -fsSL --connect-timeout 5 --max-time 20 "${auth[@]}" \
21
+ "$base/$rel" -o "$tmp" 2>/dev/null; then
22
+ mkdir -p "$SRC/$(dirname "$rel")" && mv "$tmp" "$SRC/$rel"
23
+ echo "[start] refreshed $rel"
24
+ else
25
+ rm -f "$tmp"
26
+ fi
27
+ done < <(cd "$SRC" && find . -name '*.py' -o -name '*.json' | sed 's|^\./||')
28
+ fi
29
+
30
+ cd "$COMFY_DIR"
31
+ # shellcheck disable=SC2086 — COMFY_ARGS is intentionally word-split
32
+ python -u main.py --listen 127.0.0.1 --port "$COMFY_PORT" \
33
+ --disable-auto-launch ${COMFY_ARGS:---highvram} \
34
+ > /comfyui.log 2>&1 &
35
+
36
+ exec python -u "$SRC/handler.py"
serverless/tests/endpoint_test.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Exercise a deployed RunPod serverless endpoint.
3
+
4
+ python tests/endpoint_test.py --endpoint-id XXXX main.jpg [ref.png] \
5
+ [--prompt "..."] [--mode turbo-8] [--seed 1] [--sync]
6
+
7
+ Reads RUNPOD_API_KEY from the environment. Saves returned images next to this
8
+ script and prints per-phase timings (delay/queue vs execution).
9
+ """
10
+ import argparse
11
+ import base64
12
+ import json
13
+ import os
14
+ import pathlib
15
+ import sys
16
+ import time
17
+ import urllib.request
18
+
19
+
20
+ def api(method, url, payload=None, timeout=120):
21
+ req = urllib.request.Request(
22
+ url, method=method,
23
+ data=json.dumps(payload).encode() if payload is not None else None,
24
+ headers={"Content-Type": "application/json",
25
+ "Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"})
26
+ with urllib.request.urlopen(req, timeout=timeout) as r:
27
+ return json.load(r)
28
+
29
+
30
+ def main():
31
+ ap = argparse.ArgumentParser()
32
+ ap.add_argument("images", nargs="+")
33
+ ap.add_argument("--endpoint-id", required=True)
34
+ ap.add_argument("--prompt", default="replace the outfit of the subject in the first "
35
+ "image with the outfit in the second image, don't change anything else.")
36
+ ap.add_argument("--mode", default="turbo-8")
37
+ ap.add_argument("--seed", type=int)
38
+ ap.add_argument("--sync", action="store_true", help="use /runsync instead of /run+poll")
39
+ ap.add_argument("--timeout", type=float, default=900)
40
+ args = ap.parse_args()
41
+
42
+ imgs = [{"name": os.path.basename(p),
43
+ "image": base64.b64encode(open(p, "rb").read()).decode()}
44
+ for p in args.images]
45
+ params = {"prompt": args.prompt, "mode": args.mode}
46
+ if args.seed is not None:
47
+ params["seed"] = args.seed
48
+ payload = {"input": {"images": imgs, "params": params}}
49
+ base = f"https://api.runpod.ai/v2/{args.endpoint_id}"
50
+
51
+ t0 = time.monotonic()
52
+ if args.sync:
53
+ result = api("POST", f"{base}/runsync", payload, timeout=args.timeout)
54
+ else:
55
+ job = api("POST", f"{base}/run", payload)
56
+ print(f"job {job['id']} -> {job['status']}", file=sys.stderr)
57
+ while True:
58
+ result = api("GET", f"{base}/status/{job['id']}")
59
+ if result["status"] in ("COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"):
60
+ break
61
+ if time.monotonic() - t0 > args.timeout:
62
+ sys.exit(f"timed out after {args.timeout}s (status {result['status']})")
63
+ print(f" {result['status']} ... {time.monotonic()-t0:.0f}s", file=sys.stderr)
64
+ time.sleep(2)
65
+ wall = time.monotonic() - t0
66
+
67
+ if result.get("status") != "COMPLETED":
68
+ sys.exit(f"FAILED: {json.dumps(result, indent=2)[:3000]}")
69
+ out = result["output"]
70
+ if "error" in out:
71
+ sys.exit(f"handler error: {out['error']}")
72
+ for i, img in enumerate(out["images"]):
73
+ dest = pathlib.Path(__file__).parent / f"endpoint-out-{i}-{img['filename']}"
74
+ dest.write_bytes(base64.b64decode(img["data"]))
75
+ print(f"saved {dest}")
76
+ print(f"status=COMPLETED wall={wall:.1f}s delayTime={result.get('delayTime')}ms "
77
+ f"executionTime={result.get('executionTime')}ms\n"
78
+ f"handler timings={out.get('timings')} seed={out.get('seed')}")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
serverless/tests/local_test.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Drive handler.py directly against a locally running ComfyUI (no RunPod).
3
+
4
+ python tests/local_test.py main.jpg [ref.png] [--prompt "..."] [--mode turbo-8]
5
+
6
+ Saves returned images next to this script and prints timings.
7
+ """
8
+ import argparse
9
+ import base64
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import sys
14
+ import time
15
+
16
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
17
+ import handler as H # noqa: E402
18
+
19
+
20
+ def main():
21
+ ap = argparse.ArgumentParser()
22
+ ap.add_argument("images", nargs="+", help="input image file(s): main [ref]")
23
+ ap.add_argument("--prompt", default="replace the outfit of the subject in the first "
24
+ "image with the outfit in the second image, don't change anything else.")
25
+ ap.add_argument("--mode", default="turbo-8")
26
+ ap.add_argument("--seed", type=int)
27
+ ap.add_argument("--set", dest="sets", action="append", default=[],
28
+ metavar="NODE.INPUT=JSONVALUE")
29
+ args = ap.parse_args()
30
+
31
+ imgs = []
32
+ for p in args.images:
33
+ imgs.append({"name": os.path.basename(p),
34
+ "image": base64.b64encode(open(p, "rb").read()).decode()})
35
+ params = {"prompt": args.prompt, "mode": args.mode}
36
+ if args.seed is not None:
37
+ params["seed"] = args.seed
38
+ overrides = {}
39
+ for s in args.sets:
40
+ k, v = s.split("=", 1)
41
+ try:
42
+ overrides[k] = json.loads(v)
43
+ except json.JSONDecodeError:
44
+ overrides[k] = v
45
+
46
+ boot = H.wait_for_comfy()
47
+ print(f"comfy ready ({boot:.1f}s)")
48
+ t0 = time.monotonic()
49
+ out = H.handler({"id": f"local-{int(t0)}",
50
+ "input": {"images": imgs, "params": params, "set": overrides}})
51
+ if "error" in out:
52
+ sys.exit(f"ERROR: {out['error']}")
53
+ for i, img in enumerate(out["images"]):
54
+ dest = pathlib.Path(__file__).parent / f"local-out-{i}-{img['filename']}"
55
+ dest.write_bytes(base64.b64decode(img["data"]))
56
+ print(f"saved {dest}")
57
+ print(f"seed={out['seed']} timings={out['timings']} wall={time.monotonic()-t0:.1f}s")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()