serverless: RunPod worker (handler, build/deploy scripts, Dockerfile, qwen-edit-turbo endpoint)
Browse files- serverless/Dockerfile +59 -0
- serverless/README.md +115 -0
- serverless/build.sh +124 -0
- serverless/deploy.sh +69 -0
- serverless/endpoints/qwen-edit-turbo/endpoint.json +21 -0
- serverless/endpoints/qwen-edit-turbo/params/qwen-edit-turbo-v4.json +24 -0
- serverless/endpoints/qwen-edit-turbo/workflows/qwen-edit-turbo-v4.json +758 -0
- serverless/handler.py +307 -0
- serverless/start.sh +36 -0
- serverless/tests/endpoint_test.py +82 -0
- serverless/tests/local_test.py +61 -0
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()
|