diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 88f5cc4..0aeda7f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -89,6 +89,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Report self-hosted runner disk + run: | + df -h / /home "$RUNNER_WORKSPACE" || true + docker system df || true - name: Build Docker image run: | if [[ -z "${{ needs.check-comfyui-version.outputs.version_to_use }}" ]]; then @@ -99,6 +103,12 @@ jobs: echo "final_update_version is empty" >&2 exit 1 fi + IMAGE="michaelgold/comfy3d:${{ needs.check-comfyui-version.outputs.final_update_version }}-${{ matrix.cuda.tag_suffix }}" + if docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Using cached local Docker image: $IMAGE" + exit 0 + fi + echo "Cached Docker image not found; building: $IMAGE" docker build \ --build-arg VERSION=${{ needs.check-comfyui-version.outputs.version_to_use }} \ --build-arg UPDATE_VERSION=${{ needs.check-comfyui-version.outputs.final_update_version }} \ @@ -108,23 +118,130 @@ jobs: --build-arg CUMM_CUDA_VERSION=${{ matrix.cuda.cumm_cuda_version }} \ --build-arg CUDA_PACKAGE_VERSION=${{ matrix.cuda.cuda_package_version }} \ --build-arg CUDA_ARCH_LIST="${{ matrix.cuda.cuda_arch_list }}" \ - -t michaelgold/comfy3d:${{ needs.check-comfyui-version.outputs.final_update_version }}-${{ matrix.cuda.tag_suffix }} \ + -t "$IMAGE" \ -f Docker/Dockerfile . - name: Test container run: | + cleanup() { + status=$? + if [ "$status" -ne 0 ]; then + echo "::group::comfy-test diagnostics" + docker ps -a --filter name=comfy-test --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true + docker logs --tail 500 comfy-test || true + nvidia-smi || true + echo "::endgroup::" + fi + docker rm -f comfy-test || true + exit "$status" + } + trap cleanup EXIT + free_comfy_memory() { + curl -sS -X POST http://127.0.0.1:8234/free \ + -H 'Content-Type: application/json' \ + -d '{"unload_models": true, "free_memory": true}' || true + sleep 5 + } # Remove existing container if it exists docker rm -f comfy-test || true - docker run -d -p 8234:8188 --name comfy-test --runtime=nvidia --gpus all michaelgold/comfy3d:${{ needs.check-comfyui-version.outputs.final_update_version }}-${{ matrix.cuda.tag_suffix }} + rm -rf /tmp/comfy3d-unirig-override + git clone --depth 1 https://github.com/michaelgold/ComfyUI-UniRig.git /tmp/comfy3d-unirig-override + git -C /tmp/comfy3d-unirig-override fetch --depth 1 origin c68895bd9f4ad44451bec335be97f0b98ccddabd + git -C /tmp/comfy3d-unirig-override checkout c68895bd9f4ad44451bec335be97f0b98ccddabd + mkdir -p "$HOME/.cache/comfy3d-models" + docker run -d -p 8234:8188 --name comfy-test --runtime=nvidia --gpus all \ + -v "$HOME/.cache/comfy3d-models:/app/comfy/models" \ + -v "/tmp/comfy3d-unirig-override:/app/comfy/custom_nodes/ComfyUI-UniRig:ro" \ + michaelgold/comfy3d:${{ needs.check-comfyui-version.outputs.final_update_version }}-${{ matrix.cuda.tag_suffix }} sleep 150 - docker logs comfy-test > logs.txt + docker logs comfy-test > logs.txt 2>&1 cat logs.txt if grep -q "0.0.0.0" logs.txt; then echo "✅ ComfyUI server started" else echo "❌ ComfyUI server did not start" && exit 1 fi - docker stop comfy-test - docker rm comfy-test + rm -rf /tmp/remesher-workflows /tmp/remesher-smoke + git clone --depth 1 https://github.com/remesher/remesher.git /tmp/remesher-workflows + git -C /tmp/remesher-workflows fetch --depth 1 origin 2d37cbbe63151327965fbd0db569d1e63c7bfb64 + git -C /tmp/remesher-workflows checkout 2d37cbbe63151327965fbd0db569d1e63c7bfb64 + python utils/check_remesher_workflows.py /tmp/remesher-workflows/examples --server http://127.0.0.1:8234 + if [[ "${{ matrix.cuda.tag_suffix }}" == "cuda13.0" ]]; then + cd /tmp/remesher-workflows + printf '{"server_url":"http://127.0.0.1:8234"}\n' > config.json + mkdir -p /tmp/remesher-smoke/images /tmp/remesher-smoke/glb /tmp/remesher-smoke/rigged + python3 -m venv /tmp/remesher-smoke-venv + /tmp/remesher-smoke-venv/bin/python -m pip install --upgrade pip + /tmp/remesher-smoke-venv/bin/python -m pip install /tmp/remesher-workflows + docker exec -e HF_TOKEN="${{ secrets.HF_TOKEN }}" comfy-test bash -lc ' + set -euo pipefail + download_model() { + url="$1" + dest="$2" + mkdir -p "$(dirname "$dest")" + if [ -s "$dest" ]; then + echo "Model already present: $dest" + return 0 + fi + echo "Downloading model: $url -> $dest" + tmp="${dest}.tmp" + rm -f "$tmp" + auth_args=() + if [ -n "${HF_TOKEN:-}" ]; then + auth_args=(-H "Authorization: Bearer ${HF_TOKEN}") + fi + curl -L --fail --retry 5 --retry-delay 10 --connect-timeout 30 "${auth_args[@]}" -o "$tmp" "$url" + test -s "$tmp" + mv "$tmp" "$dest" + } + download_model "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors" "/app/comfy/models/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors" + download_model "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" "/app/comfy/models/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" + download_model "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" "/app/comfy/models/vae/qwen_image_vae.safetensors" + download_model "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors" "/app/comfy/models/loras/Qwen-Image-Lightning-4steps-V1.0.safetensors" + download_model "https://huggingface.co/facebook/dinov3-vitl16-pretrain-lvd1689m/resolve/main/config.json" "/app/comfy/models/facebook/dinov3-vitl16-pretrain-lvd1689m/config.json" + download_model "https://huggingface.co/facebook/dinov3-vitl16-pretrain-lvd1689m/resolve/main/preprocessor_config.json" "/app/comfy/models/facebook/dinov3-vitl16-pretrain-lvd1689m/preprocessor_config.json" + download_model "https://huggingface.co/facebook/dinov3-vitl16-pretrain-lvd1689m/resolve/main/model.safetensors" "/app/comfy/models/facebook/dinov3-vitl16-pretrain-lvd1689m/model.safetensors" + download_model "https://huggingface.co/jasongzy/Make-It-Animatable/resolve/main/output/best/new/bw.pth" "/app/comfy/models/mia/bw.pth" + download_model "https://huggingface.co/jasongzy/Make-It-Animatable/resolve/main/output/best/new/bw_normal.pth" "/app/comfy/models/mia/bw_normal.pth" + download_model "https://huggingface.co/jasongzy/Make-It-Animatable/resolve/main/output/best/new/joints.pth" "/app/comfy/models/mia/joints.pth" + download_model "https://huggingface.co/jasongzy/Make-It-Animatable/resolve/main/output/best/new/joints_coarse.pth" "/app/comfy/models/mia/joints_coarse.pth" + download_model "https://huggingface.co/jasongzy/Make-It-Animatable/resolve/main/output/best/new/pose.pth" "/app/comfy/models/mia/pose.pth" + ' + /tmp/remesher-smoke-venv/bin/comfy-prompt-cli text-to-image \ + --prompt "cute 3d robot character in a clean t-pose, arms straight out horizontally, front view, centered, full body, neutral background, toy-like rounded shapes" \ + --seed 12345 \ + --filename-prefix ci_smoke_robot_tpose \ + --timeout 3600 \ + --out-dir /tmp/remesher-smoke/images + SMOKE_IMAGE="$(find /tmp/remesher-smoke/images -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \) | sort | head -n 1)" + test -s "$SMOKE_IMAGE" + echo "Remesher smoke image: $SMOKE_IMAGE" + free_comfy_memory + /tmp/remesher-smoke-venv/bin/comfy-prompt-cli image-to-glb \ + --image "$SMOKE_IMAGE" \ + --mesh-seed 23456 \ + --target-face-num 50000 \ + --filename-prefix ci_smoke_robot_glb \ + --texture-seed 34567 \ + --timeout 3600 \ + --out-dir /tmp/remesher-smoke/glb + SMOKE_GLB="$(find /tmp/remesher-smoke/glb -type f -iname '*.glb' | sort | head -n 1)" + test -s "$SMOKE_GLB" + echo "Remesher smoke GLB: $SMOKE_GLB" + free_comfy_memory + /tmp/remesher-smoke-venv/bin/comfy-prompt-cli rig-glb \ + --mesh "$SMOKE_GLB" \ + --glb-name ci_smoke_robot_rigged \ + --precision fp16 \ + --attn-backend sdpa \ + --target-face-count 50000 \ + --no-embed-textures \ + --timeout 3600 \ + --out-dir /tmp/remesher-smoke/rigged + SMOKE_RIGGED_GLB="$(find /tmp/remesher-smoke/rigged -type f -iname '*.glb' | sort | head -n 1)" + test -s "$SMOKE_RIGGED_GLB" + echo "Remesher smoke rigged GLB: $SMOKE_RIGGED_GLB" + cd "$GITHUB_WORKSPACE" + fi push: needs: [check-comfyui-version, build-and-test] diff --git a/Docker/Dockerfile b/Docker/Dockerfile index b9cd4f8..6b2dc70 100644 --- a/Docker/Dockerfile +++ b/Docker/Dockerfile @@ -309,9 +309,9 @@ RUN python /app/utils/node_install.py https://github.com/kijai/ComfyUI-Hunyuan3D RUN python /app/utils/node_install.py https://github.com/calcuis/gguf --version e802c7831507af49eefa40324eb78378eec4f3cf -RUN python /app/utils/node_install.py https://github.com/PozzettiAndrea/ComfyUI-UniRig --version 30503fb0bf591f4c4d11f1e61580bd66ecdda8cd --no-build-isolation +RUN python /app/utils/node_install.py https://github.com/michaelgold/ComfyUI-UniRig --version c68895bd9f4ad44451bec335be97f0b98ccddabd --no-build-isolation -RUN python /app/utils/node_install.py https://github.com/PozzettiAndrea/ComfyUI-GeometryPack --version 3cb49fbd7ba911c10a72fc767926a029f97798ab --no-build-isolation +RUN python /app/utils/node_install.py https://github.com/michaelgold/ComfyUI-GeometryPack --version 8229aa65ec026b4b87802e4ac3b28022c8dd628b --no-build-isolation RUN python /app/utils/node_install.py https://github.com/visualbruno/ComfyUI-Trellis2 --version 3c7e46b265c1e28f523889ad24d65708cdeee7f8 --no-build-isolation @@ -340,7 +340,6 @@ RUN uv pip install diffusers==0.34.0 transformers==4.56.0 RUN uv pip install --extra-index-url https://michaelgold.github.io/buildbpy/ bpy==5.0.1 - # ---------- Stage 5: final runtime ---------- ARG CUDA_PACKAGE_VERSION FROM nvidia/cuda:${CUDA_RUNTIME_TAG} AS final @@ -349,6 +348,7 @@ ARG CUDA_PACKAGE_VERSION RUN apt-get update && apt-get install -y --allow-change-held-packages --no-install-recommends \ libgl1 libegl1 libopengl0 libglu1-mesa \ libcusparse-${CUDA_PACKAGE_VERSION} libcublas-${CUDA_PACKAGE_VERSION} libcusolver-${CUDA_PACKAGE_VERSION} \ + gcc g++ \ wget curl git \ libglib2.0-0 libglib2.0-dev libgthread-2.0-0 \ libtbbmalloc2 \ @@ -381,11 +381,12 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV NVIDIA_VISIBLE_DEVICES=all -ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics +ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility ENV PYOPENGL_PLATFORM=egl ENV FORCE_CUDA="1" ENV CUDA_HOME="/usr/local/cuda" ENV LD_LIBRARY_PATH="/usr/local/lib:/usr/lib/x86_64-linux-gnu:/usr/local/cuda/lib64:/usr/local/cuda/targets/x86_64-linux/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + # Prevent spconv from switching algorithms at runtime ARG CUDA_ARCH_LIST diff --git a/utils/check_comfyui_latest.py b/utils/check_comfyui_latest.py index d02818f..5a2576f 100644 --- a/utils/check_comfyui_latest.py +++ b/utils/check_comfyui_latest.py @@ -1,56 +1,89 @@ -import requests import json import os +import sys +import urllib.error +import urllib.request + + +GITHUB_API = "https://api.github.com/repos/comfyanonymous/ComfyUI" + + +def github_json(path): + token = os.getenv("GITHUB_TOKEN") + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "comfy3d-check-comfyui-latest", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + request = urllib.request.Request(f"{GITHUB_API}/{path}", headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API request failed for {path}: HTTP {exc.code}: {body}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"GitHub API request failed for {path}: {exc}") from exc + def get_latest_tag(): - response = requests.get('https://api.github.com/repos/comfyanonymous/ComfyUI/tags') - tags = response.json() - return tags[0]['name'] if tags else None + tags = github_json("tags") + if not isinstance(tags, list): + raise RuntimeError(f"GitHub tags response was not a list: {tags!r}") + return tags[0].get("name") if tags else None + def get_latest_commit(): - response = requests.get('https://api.github.com/repos/comfyanonymous/ComfyUI/commits/master') - commit = response.json() - return commit['sha'] if commit else None + commit = github_json("commits/master") + if not isinstance(commit, dict): + raise RuntimeError(f"GitHub commit response was not an object: {commit!r}") + return commit.get("sha") + def read_version_info(file_path): if os.path.exists(file_path): - with open(file_path, 'r') as file: + with open(file_path, "r") as file: return json.load(file) return {"base_version": "", "update_version": "", "previous_commit": ""} + def write_version_info(file_path, base_version, update_version, commit): - with open(file_path, 'w') as file: + with open(file_path, "w") as file: json.dump({ - "base_version": base_version, - "update_version": update_version, - "previous_commit": commit + "base_version": base_version, + "update_version": update_version, + "previous_commit": commit, }, file) + def main(): - file_path = 'utils/comfyui_version_info.json' + file_path = "utils/comfyui_version_info.json" version_info = read_version_info(file_path) latest_tag = get_latest_tag() latest_commit = get_latest_commit() # Check if we have a new tag (update_version changes) - new_tag = latest_tag != version_info.get('update_version') - new_commit = latest_commit != version_info.get('previous_commit') + new_tag = latest_tag != version_info.get("update_version") + new_commit = latest_commit != version_info.get("previous_commit") if new_tag or new_commit: # Keep the existing base_version unless it's empty - base_version = version_info.get('base_version') or latest_tag + base_version = version_info.get("base_version") or latest_tag write_version_info(file_path, base_version, latest_tag, latest_commit) # Write outputs to $GITHUB_OUTPUT - github_output = os.getenv('GITHUB_OUTPUT') + github_output = os.getenv("GITHUB_OUTPUT") if github_output: # Resolve versions from GitHub data (tag preferred, then commit) - git_version = latest_tag or latest_commit or '' - base_version = version_info.get('base_version') or git_version + git_version = latest_tag or latest_commit or "" + base_version = version_info.get("base_version") or git_version update_version = git_version - - with open(github_output, 'a') as output_file: + + with open(github_output, "a") as output_file: output_file.write(f"new_tag={str(new_tag).lower()}\n") output_file.write(f"new_commit={str(new_commit).lower()}\n") output_file.write(f"latest_tag={latest_tag or ''}\n") @@ -58,5 +91,10 @@ def main(): output_file.write(f"base_version={base_version}\n") output_file.write(f"update_version={update_version}\n") + if __name__ == "__main__": - main() + try: + main() + except Exception as exc: + print(f"check_comfyui_latest.py failed: {exc}", file=sys.stderr) + raise diff --git a/utils/check_remesher_workflows.py b/utils/check_remesher_workflows.py new file mode 100644 index 0000000..3d4b64a --- /dev/null +++ b/utils/check_remesher_workflows.py @@ -0,0 +1,84 @@ +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +IGNORED_CLASS_TYPES = { + # ComfyUI workflow/comment helper nodes may appear in exported files but are + # not execution nodes that must be registered in /object_info. + "Note", +} + + +def load_required_class_types(workflow_dir: Path) -> dict[str, set[str]]: + by_file: dict[str, set[str]] = {} + for path in sorted(workflow_dir.glob("*.json")): + data = json.loads(path.read_text()) + classes = set() + for node in data.values(): + if not isinstance(node, dict): + continue + class_type = node.get("class_type") + if isinstance(class_type, str): + classes.add(class_type) + classes -= IGNORED_CLASS_TYPES + by_file[path.name] = classes + if not by_file: + raise RuntimeError(f"No workflow JSON files found in {workflow_dir}") + return by_file + + +def fetch_object_info(server_url: str, attempts: int = 30, delay: float = 2.0) -> dict: + url = server_url.rstrip("/") + "/object_info" + last_error: Exception | None = None + for _ in range(attempts): + try: + with urllib.request.urlopen(url, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = exc + time.sleep(delay) + raise RuntimeError(f"Could not fetch {url}: {last_error}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify remesher example workflows reference nodes registered by a running ComfyUI server." + ) + parser.add_argument("workflow_dir", type=Path) + parser.add_argument("--server", default="http://127.0.0.1:8234") + args = parser.parse_args() + + by_file = load_required_class_types(args.workflow_dir) + object_info = fetch_object_info(args.server) + registered = set(object_info.keys()) + + missing_by_file = { + name: sorted(classes - registered) + for name, classes in by_file.items() + if classes - registered + } + + print("Remesher workflow node registration check") + print(f"Server: {args.server}") + print(f"Workflows checked: {len(by_file)}") + print(f"Registered ComfyUI node classes: {len(registered)}") + for name, classes in by_file.items(): + print(f"- {name}: {len(classes)} node classes") + + if missing_by_file: + print("Missing required node classes:", file=sys.stderr) + for name, missing in missing_by_file.items(): + print(f"- {name}: {', '.join(missing)}", file=sys.stderr) + return 1 + + print("PASS remesher workflows: all referenced node classes are registered") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())