Spaces:
Running on Zero
Running on Zero
Commit ·
555715e
0
Parent(s):
AnimoFlow hosted-demo wrapper — initial public release (v0.1.0-beta)
Browse files- .dockerignore +25 -0
- .env.example +47 -0
- .github/workflows/cla.yml +28 -0
- .gitignore +25 -0
- CONTRIBUTING.md +27 -0
- Dockerfile +286 -0
- LICENSE +83 -0
- README.md +235 -0
- SECURITY.md +17 -0
- animoflow_models/__init__.py +18 -0
- animoflow_models/registry.py +334 -0
- app.py +270 -0
- bootstrap.py +1416 -0
- entrypoint.sh +33 -0
- escape_hatch/__init__.py +18 -0
- escape_hatch/invoke.py +194 -0
- packages.txt +3 -0
- pipeline_hf.py +920 -0
- requirements.txt +54 -0
- scripts/download_weights.sh +108 -0
- scripts/run_inference_kimodo.py +337 -0
- spaces_compat.py +74 -0
- tests/__init__.py +0 -0
- tests/conftest.py +70 -0
- tests/test_app_imports.py +67 -0
- tests/test_pipeline_hf.py +341 -0
- tests/test_spaces_compat.py +82 -0
- ui.py +518 -0
.dockerignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Don't ship local junk into the image
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
.pytest_cache
|
| 6 |
+
.venv
|
| 7 |
+
venv
|
| 8 |
+
.git
|
| 9 |
+
.gitignore
|
| 10 |
+
.dockerignore
|
| 11 |
+
*.egg-info
|
| 12 |
+
build
|
| 13 |
+
dist
|
| 14 |
+
.env
|
| 15 |
+
.env.local
|
| 16 |
+
output
|
| 17 |
+
.weights
|
| 18 |
+
.checkpoints
|
| 19 |
+
.DS_Store
|
| 20 |
+
.vscode
|
| 21 |
+
.idea
|
| 22 |
+
README.md
|
| 23 |
+
LICENSE
|
| 24 |
+
docs
|
| 25 |
+
tests
|
.env.example
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# animoflow-app environment — copy to .env for local dev (never commit .env)
|
| 2 |
+
|
| 3 |
+
# Where the orchestrator writes generated FBX/GLB
|
| 4 |
+
OUTPUT_DIR=/tmp/animoflow-output
|
| 5 |
+
|
| 6 |
+
# Where Y_bot.fbx etc. live (cloned from comfyui-animoflow at Docker build)
|
| 7 |
+
CHARACTERS_DIR=/opt/comfyui-animoflow/characters
|
| 8 |
+
|
| 9 |
+
# WEB_DIR controls whether the AnimoFlow web app's static SPA mounts. Set to a
|
| 10 |
+
# non-existent path on HF so Gradio takes the "/" route instead.
|
| 11 |
+
WEB_DIR=/nonexistent
|
| 12 |
+
|
| 13 |
+
# Upstream model paths — set by Dockerfile, used by inference modules.
|
| 14 |
+
# For local dev outside Docker, point at your local clones.
|
| 15 |
+
MDM_PATH=/opt/mdm-codes
|
| 16 |
+
MOMASK_PATH=/opt/momask-codes
|
| 17 |
+
PRIORMDM_PATH=/opt/priormdm-codes
|
| 18 |
+
HML_DATASET_DIR=/opt/priormdm-codes/dataset/HumanML3D
|
| 19 |
+
|
| 20 |
+
# Where MDM-family checkpoints live. download_weights.sh populates this on first boot.
|
| 21 |
+
CHECKPOINTS_DIR=/opt/checkpoints
|
| 22 |
+
|
| 23 |
+
# Blender binary (required for retarget + GLB export). Leave unset to let
|
| 24 |
+
# pipeline_hf._find_blender() auto-detect:
|
| 25 |
+
# 1. blender on PATH
|
| 26 |
+
# 2. /Applications/Blender.app/Contents/MacOS/Blender (macOS)
|
| 27 |
+
# 3. /opt/blender/blender (Docker image)
|
| 28 |
+
# Override here if your install lives elsewhere.
|
| 29 |
+
# BLENDER_BIN=/Applications/Blender.app/Contents/MacOS/Blender
|
| 30 |
+
|
| 31 |
+
# These point at non-existent endpoints on HF (no model servers run as separate
|
| 32 |
+
# processes). animoflow-api's _health_poller will log "unreachable" but won't crash.
|
| 33 |
+
MDM_ENDPOINT=http://127.0.0.1:65500
|
| 34 |
+
PRIORMDM_ENDPOINT=http://127.0.0.1:65501
|
| 35 |
+
MOMASK_ENDPOINT=http://127.0.0.1:65502
|
| 36 |
+
MOMASK_PLUS_ENDPOINT=http://127.0.0.1:65503
|
| 37 |
+
KIMODO_ENDPOINT=http://127.0.0.1:65504
|
| 38 |
+
COMFYUI_URL=http://127.0.0.1:65505
|
| 39 |
+
|
| 40 |
+
# Health poll cadence — bumped high so we don't spam logs with "unreachable"
|
| 41 |
+
HEALTH_POLL_INTERVAL=600
|
| 42 |
+
|
| 43 |
+
# Gradio / FastAPI port (HF Spaces convention is 7860)
|
| 44 |
+
PORT=7860
|
| 45 |
+
|
| 46 |
+
# Disable PyTorch CUDA emulation warnings outside @spaces.GPU
|
| 47 |
+
TRANSFORMERS_NO_ADVISORY_WARNINGS=1
|
.github/workflows/cla.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: "CLA Assistant"
|
| 2 |
+
on:
|
| 3 |
+
issue_comment:
|
| 4 |
+
types: [created]
|
| 5 |
+
pull_request_target:
|
| 6 |
+
types: [opened, closed, synchronize]
|
| 7 |
+
|
| 8 |
+
permissions:
|
| 9 |
+
actions: write
|
| 10 |
+
contents: read
|
| 11 |
+
pull-requests: write
|
| 12 |
+
statuses: write
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
cla:
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
steps:
|
| 18 |
+
- uses: contributor-assistant/github-action@v2.6.1
|
| 19 |
+
env:
|
| 20 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 21 |
+
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_PAT }}
|
| 22 |
+
with:
|
| 23 |
+
path-to-signatures: "signatures/version1/cla.json"
|
| 24 |
+
path-to-document: "https://github.com/AnimoFlow/legal/blob/main/CLA/individual.md"
|
| 25 |
+
remote-organization-name: "AnimoFlow"
|
| 26 |
+
remote-repository-name: "cla-signatures"
|
| 27 |
+
branch: "main"
|
| 28 |
+
allowlist: "AnimoFlow,dependabot[bot]"
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.venv*/
|
| 6 |
+
venv/
|
| 7 |
+
*.egg-info/
|
| 8 |
+
build/
|
| 9 |
+
dist/
|
| 10 |
+
|
| 11 |
+
# Local secrets / runtime
|
| 12 |
+
.env
|
| 13 |
+
.env.local
|
| 14 |
+
output/
|
| 15 |
+
.weights/
|
| 16 |
+
.checkpoints/
|
| 17 |
+
|
| 18 |
+
# OS
|
| 19 |
+
.DS_Store
|
| 20 |
+
Thumbs.db
|
| 21 |
+
|
| 22 |
+
# Editor
|
| 23 |
+
.vscode/
|
| 24 |
+
.idea/
|
| 25 |
+
*.swp
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to animoflow-app
|
| 2 |
+
|
| 3 |
+
This repository is the **deployment wrapper** for the hosted AnimoFlow demo
|
| 4 |
+
(the Hugging Face Space). Most contributions belong in the components it
|
| 5 |
+
wraps:
|
| 6 |
+
|
| 7 |
+
- API surface, job contract, error copy → [animoflow-api](https://github.com/AnimoFlow/animoflow-api)
|
| 8 |
+
- Models, pipeline nodes, retargeting → [comfyui-animoflow](https://github.com/AnimoFlow/comfyui-animoflow)
|
| 9 |
+
- Blender addon → [animoflow-blender](https://github.com/AnimoFlow/animoflow-blender)
|
| 10 |
+
|
| 11 |
+
If your change really is about the Space deployment itself (bootstrap,
|
| 12 |
+
ZeroGPU handling, the Gradio wrapper): external contributions require a
|
| 13 |
+
one-time Contributor License Agreement — a bot will ask on your first PR.
|
| 14 |
+
CLA text and the per-repo license map: [AnimoFlow/legal](https://github.com/AnimoFlow/legal).
|
| 15 |
+
|
| 16 |
+
## Development
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
pip install pytest
|
| 20 |
+
python -m pytest -q # unit tests; no GPU or Space needed
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
Note that a real end-to-end run requires the Space environment (ZeroGPU,
|
| 24 |
+
private checkpoint repos) — deployment changes are verified on the Space
|
| 25 |
+
itself by the maintainer.
|
| 26 |
+
|
| 27 |
+
Questions first? Open an issue or write to guy@animoflow.ai.
|
Dockerfile
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.6
|
| 2 |
+
#
|
| 3 |
+
# animoflow-app — HF Space + OSS local Docker image.
|
| 4 |
+
#
|
| 5 |
+
# Single-venv orchestrator with warm MDM-family models loaded at module level.
|
| 6 |
+
# Per-(comfy+model) escape-hatch venvs isolate outlier models
|
| 7 |
+
# (Kimodo NVIDIA stack, future additions).
|
| 8 |
+
#
|
| 9 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 10 |
+
# AUTH: this Dockerfile uses two BuildKit secrets, both never landing
|
| 11 |
+
# in any image layer:
|
| 12 |
+
#
|
| 13 |
+
# gh_token — read access to AnimoFlow/{comfyui-animoflow,animoflow-api}
|
| 14 |
+
# (currently private; the secret-mount keeps working
|
| 15 |
+
# after either repo flips public).
|
| 16 |
+
# hf_token — read access to AnimoFlow/animoflow-checkpoints (the
|
| 17 |
+
# private HF Hub model repo where MDM weights live; baked
|
| 18 |
+
# into the image at build time).
|
| 19 |
+
#
|
| 20 |
+
# HF Spaces autopopulate Space-level secrets with these names. For local
|
| 21 |
+
# builds you pass them via `--secret id=<name>,src=<path-to-token-file>`.
|
| 22 |
+
#
|
| 23 |
+
# Mac local build:
|
| 24 |
+
# gh auth token > /tmp/gh_token
|
| 25 |
+
# DOCKER_BUILDKIT=1 docker build \
|
| 26 |
+
# --secret id=gh_token,src=/tmp/gh_token \
|
| 27 |
+
# -t animoflow/app .
|
| 28 |
+
# rm /tmp/gh_token
|
| 29 |
+
#
|
| 30 |
+
# HF Space build: configure a Space secret named `gh_token` in
|
| 31 |
+
# Settings → Variables and secrets. HF passes it to the build automatically.
|
| 32 |
+
#
|
| 33 |
+
# Run (OSS local, after build):
|
| 34 |
+
# docker run --rm -p 7860:7860 animoflow/app
|
| 35 |
+
#
|
| 36 |
+
# Build args let you pin upstream SHAs reproducibly. CI bumps these
|
| 37 |
+
# deliberately, never via `git pull` behind our back (wrap upstream
|
| 38 |
+
# repos, don't fork them).
|
| 39 |
+
|
| 40 |
+
ARG PYTHON_VERSION=3.11
|
| 41 |
+
ARG BLENDER_VERSION=4.2.5
|
| 42 |
+
ARG COMFYUI_ANIMOFLOW_REPO=https://github.com/AnimoFlow/comfyui-animoflow.git
|
| 43 |
+
ARG ANIMOFLOW_API_REPO=https://github.com/AnimoFlow/animoflow-api.git
|
| 44 |
+
ARG MDM_REPO=https://github.com/GuyTevet/motion-diffusion-model.git
|
| 45 |
+
ARG MOMASK_REPO=https://github.com/EricGuo5513/momask-codes.git
|
| 46 |
+
# SHA pins — update deliberately, not via `git pull`
|
| 47 |
+
ARG COMFYUI_ANIMOFLOW_SHA=main
|
| 48 |
+
ARG ANIMOFLOW_API_SHA=main
|
| 49 |
+
ARG MDM_SHA=main
|
| 50 |
+
ARG MOMASK_SHA=main
|
| 51 |
+
|
| 52 |
+
# Baked checkpoints — pulled from a private HF Hub model repo at build
|
| 53 |
+
# time using the `hf_token` BuildKit secret. SHA-pinned for reproducibility;
|
| 54 |
+
# bump deliberately. The repo holds full training artifacts (final + earlier
|
| 55 |
+
# checkpoints + optimizer state + eval logs) but the Dockerfile filters via
|
| 56 |
+
# `allow_patterns` to bake only the inference-essential files.
|
| 57 |
+
ARG ANIMOFLOW_CHECKPOINTS_REPO=AnimoFlow/animoflow-checkpoints
|
| 58 |
+
ARG ANIMOFLOW_CHECKPOINTS_REVISION=9c8b352f849c5e81869a50f71667d43c064571eb
|
| 59 |
+
|
| 60 |
+
FROM python:${PYTHON_VERSION}-slim AS base
|
| 61 |
+
|
| 62 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 63 |
+
# System deps
|
| 64 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 65 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 66 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 67 |
+
git curl wget ca-certificates xz-utils \
|
| 68 |
+
xvfb xauth \
|
| 69 |
+
libxi6 libxrender1 libxxf86vm1 libxfixes3 libxkbcommon0 \
|
| 70 |
+
libsm6 libxext6 libgl1 libglu1-mesa libegl1 \
|
| 71 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 72 |
+
|
| 73 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 74 |
+
# Blender 4.2 LTS (pinned tarball — apt's blender lags 3.x).
|
| 75 |
+
# Arch-aware: Docker BuildKit auto-populates TARGETARCH (amd64 / arm64).
|
| 76 |
+
# Default `docker build` (no buildx) leaves it empty and falls through to
|
| 77 |
+
# amd64, matching HF Spaces (x86_64 H200 hosts).
|
| 78 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 79 |
+
ARG BLENDER_VERSION
|
| 80 |
+
ARG TARGETARCH
|
| 81 |
+
RUN BLENDER_MAJ=$(echo "${BLENDER_VERSION}" | cut -d. -f1,2) \
|
| 82 |
+
&& case "${TARGETARCH:-amd64}" in \
|
| 83 |
+
amd64|x86_64|"") BLENDER_ARCH=linux-x64 ;; \
|
| 84 |
+
arm64|aarch64) BLENDER_ARCH=linux-arm64 ;; \
|
| 85 |
+
*) echo "Unsupported TARGETARCH=${TARGETARCH}" >&2 ; exit 1 ;; \
|
| 86 |
+
esac \
|
| 87 |
+
&& echo "Building for ${TARGETARCH:-amd64} → blender-${BLENDER_VERSION}-${BLENDER_ARCH}.tar.xz" \
|
| 88 |
+
&& curl -fsSL "https://download.blender.org/release/Blender${BLENDER_MAJ}/blender-${BLENDER_VERSION}-${BLENDER_ARCH}.tar.xz" -o /tmp/blender.tar.xz \
|
| 89 |
+
&& mkdir -p /opt/blender \
|
| 90 |
+
&& tar -xf /tmp/blender.tar.xz -C /opt/blender --strip-components=1 \
|
| 91 |
+
&& rm /tmp/blender.tar.xz \
|
| 92 |
+
&& /opt/blender/blender --version
|
| 93 |
+
ENV BLENDER_BIN=/opt/blender/blender
|
| 94 |
+
|
| 95 |
+
# ────────────────��────────────────────────────────────────────────────
|
| 96 |
+
# Clone wrapper repos — wrap-don't-modify, pinned by SHA build args.
|
| 97 |
+
#
|
| 98 |
+
# Both `comfyui-animoflow` and `animoflow-api` are currently private. We
|
| 99 |
+
# clone both via a BuildKit secret named `gh_token` (the token is
|
| 100 |
+
# interpolated into the URL temporarily and never lands in any image
|
| 101 |
+
# layer — we strip the remote URL after the clone). The same pattern
|
| 102 |
+
# works once either repo is flipped to public, so this keeps working
|
| 103 |
+
# across the public flip.
|
| 104 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 105 |
+
ARG COMFYUI_ANIMOFLOW_REPO
|
| 106 |
+
ARG COMFYUI_ANIMOFLOW_SHA
|
| 107 |
+
ARG ANIMOFLOW_API_REPO
|
| 108 |
+
ARG ANIMOFLOW_API_SHA
|
| 109 |
+
|
| 110 |
+
RUN --mount=type=secret,id=gh_token,required=true \
|
| 111 |
+
GH_TOKEN="$(cat /run/secrets/gh_token)" \
|
| 112 |
+
&& _AUTH_CAF=$(echo "${COMFYUI_ANIMOFLOW_REPO}" | sed -e "s|https://|https://x-access-token:${GH_TOKEN}@|") \
|
| 113 |
+
&& _AUTH_AFW=$(echo "${ANIMOFLOW_API_REPO}" | sed -e "s|https://|https://x-access-token:${GH_TOKEN}@|") \
|
| 114 |
+
&& git clone "${_AUTH_CAF}" /opt/comfyui-animoflow \
|
| 115 |
+
&& git -C /opt/comfyui-animoflow checkout "${COMFYUI_ANIMOFLOW_SHA}" \
|
| 116 |
+
&& git -C /opt/comfyui-animoflow remote set-url origin "${COMFYUI_ANIMOFLOW_REPO}" \
|
| 117 |
+
&& git clone "${_AUTH_AFW}" /opt/animoflow-api \
|
| 118 |
+
&& git -C /opt/animoflow-api checkout "${ANIMOFLOW_API_SHA}" \
|
| 119 |
+
&& git -C /opt/animoflow-api remote set-url origin "${ANIMOFLOW_API_REPO}" \
|
| 120 |
+
&& unset GH_TOKEN _AUTH_CAF _AUTH_AFW
|
| 121 |
+
|
| 122 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 123 |
+
# Clone upstream models.
|
| 124 |
+
# priorMDM and Kimodo come in later iterations.
|
| 125 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 126 |
+
ARG MDM_REPO
|
| 127 |
+
ARG MDM_SHA
|
| 128 |
+
ARG MOMASK_REPO
|
| 129 |
+
ARG MOMASK_SHA
|
| 130 |
+
RUN git clone "${MDM_REPO}" /opt/mdm-codes \
|
| 131 |
+
&& git -C /opt/mdm-codes checkout "${MDM_SHA}"
|
| 132 |
+
RUN git clone "${MOMASK_REPO}" /opt/momask-codes \
|
| 133 |
+
&& git -C /opt/momask-codes checkout "${MOMASK_SHA}"
|
| 134 |
+
|
| 135 |
+
ENV MDM_PATH=/opt/mdm-codes
|
| 136 |
+
ENV MOMASK_PATH=/opt/momask-codes
|
| 137 |
+
|
| 138 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 139 |
+
# Single Python venv (orchestrator + MDM family).
|
| 140 |
+
# `uv venv` creates a bare virtualenv (no pip bundled by default — uv
|
| 141 |
+
# expects `uv pip install --python <venv>` instead of bare pip). Use the
|
| 142 |
+
# `uv pip` form throughout.
|
| 143 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 144 |
+
RUN pip install --no-cache-dir uv==0.5.11
|
| 145 |
+
|
| 146 |
+
# Create venv + install orchestrator/MDM-family deps.
|
| 147 |
+
#
|
| 148 |
+
# `--seed` populates the venv with pip + setuptools + wheel, and
|
| 149 |
+
# `--no-build-isolation` makes uv build sdists against the target venv
|
| 150 |
+
# (which has those tools) instead of uv's pip-less isolated build env.
|
| 151 |
+
# That combo handles legacy packages whose setup.py imports pip internals,
|
| 152 |
+
# without disabling build isolation everywhere.
|
| 153 |
+
COPY requirements.txt /opt/animoflow-app/requirements.txt
|
| 154 |
+
RUN uv venv /opt/venvs/api --python ${PYTHON_VERSION} --seed \
|
| 155 |
+
&& uv pip install --python /opt/venvs/api --no-cache --no-build-isolation \
|
| 156 |
+
-r /opt/animoflow-app/requirements.txt
|
| 157 |
+
|
| 158 |
+
# NOTE: we deliberately do NOT install
|
| 159 |
+
# `comfyui-animoflow/containers/mdm/requirements.txt` here. That file pins
|
| 160 |
+
# torch==2.3.0 which downgrades our 2.4.1 pin and removes
|
| 161 |
+
# `torch.library.register_fake` — an API the upstream MDM source uses, so
|
| 162 |
+
# the wrapper falls back to placeholder mode.
|
| 163 |
+
#
|
| 164 |
+
# Instead we lift the MDM-container deps that aren't already in our pin
|
| 165 |
+
# (einops) directly into requirements.txt above. The local Docker
|
| 166 |
+
# stack uses a different topology (each model in its own container with
|
| 167 |
+
# its own venv) so the version skew there doesn't bite.
|
| 168 |
+
|
| 169 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 170 |
+
# Bake model checkpoints from the AnimoFlow/animoflow-checkpoints HF Hub
|
| 171 |
+
# model repo. Token only mounted for the duration of this RUN — never
|
| 172 |
+
# lands in any image layer. Failure here aborts the build (we don't want
|
| 173 |
+
# to ship an HF Space without weights).
|
| 174 |
+
#
|
| 175 |
+
# To override the source repo or pin a specific revision, pass build args:
|
| 176 |
+
# --build-arg ANIMOFLOW_CHECKPOINTS_REPO=AnimoFlow/different-repo
|
| 177 |
+
# --build-arg ANIMOFLOW_CHECKPOINTS_REVISION=<commit-sha>
|
| 178 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 179 |
+
ARG ANIMOFLOW_CHECKPOINTS_REPO
|
| 180 |
+
ARG ANIMOFLOW_CHECKPOINTS_REVISION
|
| 181 |
+
RUN --mount=type=secret,id=hf_token,required=true \
|
| 182 |
+
export HF_TOKEN="$(cat /run/secrets/hf_token)" \
|
| 183 |
+
&& echo "Downloading checkpoints from ${ANIMOFLOW_CHECKPOINTS_REPO}@${ANIMOFLOW_CHECKPOINTS_REVISION}…" \
|
| 184 |
+
&& /opt/venvs/api/bin/python -c "\
|
| 185 |
+
import os; \
|
| 186 |
+
from huggingface_hub import snapshot_download; \
|
| 187 |
+
path = snapshot_download( \
|
| 188 |
+
repo_id='${ANIMOFLOW_CHECKPOINTS_REPO}', \
|
| 189 |
+
revision='${ANIMOFLOW_CHECKPOINTS_REVISION}', \
|
| 190 |
+
repo_type='model', \
|
| 191 |
+
local_dir='/opt/checkpoints', \
|
| 192 |
+
allow_patterns=[ \
|
| 193 |
+
'humanml_enc_512_50steps/model000750000.pt', \
|
| 194 |
+
'humanml_enc_512_50steps/args.json', \
|
| 195 |
+
], \
|
| 196 |
+
token=os.environ['HF_TOKEN']); \
|
| 197 |
+
print('checkpoints baked at', path)" \
|
| 198 |
+
&& unset HF_TOKEN \
|
| 199 |
+
&& cp /opt/comfyui-animoflow/containers/mdm/t2m_mean.npy /opt/checkpoints/t2m_mean.npy \
|
| 200 |
+
&& cp /opt/comfyui-animoflow/containers/mdm/t2m_std.npy /opt/checkpoints/t2m_std.npy \
|
| 201 |
+
&& ls -laR /opt/checkpoints/ \
|
| 202 |
+
&& du -sh /opt/checkpoints/
|
| 203 |
+
|
| 204 |
+
# WEIGHTS_DIR is what comfyui-animoflow/containers/mdm/inference.py reads for
|
| 205 |
+
# both the model checkpoint AND the t2m_mean/std normalization arrays.
|
| 206 |
+
# Setting it as ENV makes the MDM wrapper find them without per-run config.
|
| 207 |
+
ENV WEIGHTS_DIR=/opt/checkpoints
|
| 208 |
+
|
| 209 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 210 |
+
# Multilingual prompt rewriter — Qwen2.5-1.5B-Instruct + RAFSL few-shot
|
| 211 |
+
# from the local HumanML3D caption corpus. Phase-0 verified at 90% pass
|
| 212 |
+
# rate / 0.32s server-only on ZeroGPU. See:
|
| 213 |
+
# - animoflow-api/api/rewriter.py (the consumer)
|
| 214 |
+
#
|
| 215 |
+
# All three assets SHA-pinned. Bump deliberately, not via "latest".
|
| 216 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 217 |
+
ARG REWRITER_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct
|
| 218 |
+
ARG REWRITER_MODEL_REVISION=989aa7980e4c
|
| 219 |
+
ARG REWRITER_RETRIEVER_REPO=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
| 220 |
+
ARG REWRITER_RETRIEVER_REVISION=e8f8c211226b
|
| 221 |
+
ARG REWRITER_CORPUS_REPO=AnimoFlow/rewriter-corpus
|
| 222 |
+
ARG REWRITER_CORPUS_REVISION=5b98da4d2db075d40afd59badf51f2eefe1db5e2
|
| 223 |
+
|
| 224 |
+
RUN /opt/venvs/api/bin/python -c "\
|
| 225 |
+
from huggingface_hub import snapshot_download; \
|
| 226 |
+
snapshot_download( \
|
| 227 |
+
repo_id='${REWRITER_MODEL_REPO}', \
|
| 228 |
+
revision='${REWRITER_MODEL_REVISION}', \
|
| 229 |
+
local_dir='/opt/rewriter/qwen', \
|
| 230 |
+
allow_patterns=['*.safetensors','*.json','tokenizer*','*.model','*.txt','vocab*','merges*'], \
|
| 231 |
+
); \
|
| 232 |
+
snapshot_download( \
|
| 233 |
+
repo_id='${REWRITER_RETRIEVER_REPO}', \
|
| 234 |
+
revision='${REWRITER_RETRIEVER_REVISION}', \
|
| 235 |
+
local_dir='/opt/rewriter/minilm', \
|
| 236 |
+
allow_patterns=['*.safetensors','*.json','tokenizer*','*.txt','vocab*','*.bin'], \
|
| 237 |
+
); \
|
| 238 |
+
snapshot_download( \
|
| 239 |
+
repo_id='${REWRITER_CORPUS_REPO}', \
|
| 240 |
+
revision='${REWRITER_CORPUS_REVISION}', \
|
| 241 |
+
repo_type='dataset', \
|
| 242 |
+
local_dir='/opt/rewriter/corpus', \
|
| 243 |
+
allow_patterns=['captions.json','embeddings.npy'], \
|
| 244 |
+
); \
|
| 245 |
+
print('rewriter baked at /opt/rewriter')" \
|
| 246 |
+
&& ls -laR /opt/rewriter/ \
|
| 247 |
+
&& du -sh /opt/rewriter/*
|
| 248 |
+
|
| 249 |
+
ENV REWRITER_DATA_DIR=/opt/rewriter
|
| 250 |
+
|
| 251 |
+
ENV PATH=/opt/venvs/api/bin:${PATH}
|
| 252 |
+
ENV PYTHONUNBUFFERED=1
|
| 253 |
+
|
| 254 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 255 |
+
# Copy the orchestrator code last (cache-friendly)
|
| 256 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 257 |
+
COPY . /opt/animoflow-app
|
| 258 |
+
|
| 259 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 260 |
+
# Runtime config
|
| 261 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 262 |
+
ENV OUTPUT_DIR=/tmp/animoflow-output
|
| 263 |
+
ENV CHECKPOINTS_DIR=/opt/checkpoints
|
| 264 |
+
ENV CHARACTERS_DIR=/opt/comfyui-animoflow/characters
|
| 265 |
+
ENV WEB_DIR=/nonexistent
|
| 266 |
+
# Bogus model-server URLs — animoflow-api's health poller will log "unreachable"
|
| 267 |
+
# but no separate model-server processes run on HF. Harmless.
|
| 268 |
+
ENV MDM_ENDPOINT=http://127.0.0.1:65500
|
| 269 |
+
ENV PRIORMDM_ENDPOINT=http://127.0.0.1:65501
|
| 270 |
+
ENV MOMASK_ENDPOINT=http://127.0.0.1:65502
|
| 271 |
+
ENV KIMODO_ENDPOINT=http://127.0.0.1:65504
|
| 272 |
+
ENV COMFYUI_URL=http://127.0.0.1:65505
|
| 273 |
+
ENV HEALTH_POLL_INTERVAL=600
|
| 274 |
+
ENV PORT=7860
|
| 275 |
+
|
| 276 |
+
RUN mkdir -p ${OUTPUT_DIR} ${CHECKPOINTS_DIR} \
|
| 277 |
+
&& chmod +x /opt/animoflow-app/entrypoint.sh /opt/animoflow-app/scripts/*.sh
|
| 278 |
+
|
| 279 |
+
EXPOSE 7860
|
| 280 |
+
WORKDIR /opt/animoflow-app
|
| 281 |
+
|
| 282 |
+
# Healthcheck via the FastAPI health endpoint (exposed even before Gradio mounts)
|
| 283 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
| 284 |
+
CMD curl -fsS http://localhost:7860/v1/health || exit 1
|
| 285 |
+
|
| 286 |
+
CMD ["/opt/animoflow-app/entrypoint.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Required Notice: Copyright (c) 2026 Guy Tevet ("AnimoFlow") (https://animoflow.ai)
|
| 2 |
+
|
| 3 |
+
# AnimoFlow Community License 1.0.0
|
| 4 |
+
|
| 5 |
+
<https://animoflow.ai/licenses/community/1.0.0>
|
| 6 |
+
|
| 7 |
+
> **In plain terms** — this summary is for orientation and is not part of the license: this software is free to use, forever, for (1) individuals, (2) charities and other non-profits, (3) schools, universities, and public research organizations, and (4) any organization with fewer than 100 people and less than 5,000,000 USD (2026 dollars) per year in total finances. Anyone may read the source. Larger commercial organizations may evaluate the software free for 30 days, and then need a commercial license from AnimoFlow — contact guy@animoflow.ai. This is a source-available license, not an open-source license.
|
| 8 |
+
|
| 9 |
+
## Acceptance
|
| 10 |
+
|
| 11 |
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
| 12 |
+
|
| 13 |
+
## Copyright License
|
| 14 |
+
|
| 15 |
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
| 16 |
+
|
| 17 |
+
## Distribution License
|
| 18 |
+
|
| 19 |
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
| 20 |
+
|
| 21 |
+
## Notices
|
| 22 |
+
|
| 23 |
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
| 24 |
+
|
| 25 |
+
> Required Notice: Copyright (c) 2026 Guy Tevet ("AnimoFlow") (https://animoflow.ai)
|
| 26 |
+
|
| 27 |
+
## Changes and New Works License
|
| 28 |
+
|
| 29 |
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
| 30 |
+
|
| 31 |
+
## Patent License
|
| 32 |
+
|
| 33 |
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
| 34 |
+
|
| 35 |
+
## Fair Use
|
| 36 |
+
|
| 37 |
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
| 38 |
+
|
| 39 |
+
## Personal Uses
|
| 40 |
+
|
| 41 |
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
| 42 |
+
|
| 43 |
+
## Noncommercial Organizations
|
| 44 |
+
|
| 45 |
+
Use by any charitable organization, educational institution, or public research organization is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. However, use is not use for a permitted purpose under this section if it is primarily for the benefit of another organization that has control over, is under the control of, or is under common control with the organization using the software, unless that other organization's own use would be use for a permitted purpose under these terms.
|
| 46 |
+
|
| 47 |
+
## Small Organizations
|
| 48 |
+
|
| 49 |
+
Use of the software for the benefit of your company is use for a permitted purpose if your company has fewer than 100 total individuals working as employees and independent contractors, and less than 5,000,000 USD (2026) total finances in the prior tax year. Adjust this financial threshold for inflation according to the United States Bureau of Labor Statistics' consumer price index for all urban consumers, U.S. city average, for all items, not seasonally adjusted, with 1982–1984=100 reference base.
|
| 50 |
+
|
| 51 |
+
## Evaluation
|
| 52 |
+
|
| 53 |
+
Use of the software for no more than 30 consecutive calendar days to decide whether to obtain a commercial license from the licensor is use for a permitted purpose. This evaluation use must not include use in production or use for the benefit of anyone outside your company.
|
| 54 |
+
|
| 55 |
+
## No Other Rights
|
| 56 |
+
|
| 57 |
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
| 58 |
+
|
| 59 |
+
## Patent Defense
|
| 60 |
+
|
| 61 |
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
| 62 |
+
|
| 63 |
+
## Violations
|
| 64 |
+
|
| 65 |
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
| 66 |
+
|
| 67 |
+
## No Liability
|
| 68 |
+
|
| 69 |
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
| 70 |
+
|
| 71 |
+
## Definitions
|
| 72 |
+
|
| 73 |
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
| 74 |
+
|
| 75 |
+
**You** refers to the individual or entity agreeing to these terms.
|
| 76 |
+
|
| 77 |
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
| 78 |
+
|
| 79 |
+
**Total finances** means the largest of your company's aggregate gross revenue, entire budget, and total funding received, in each case calculated across your company as a whole for the prior tax year.
|
| 80 |
+
|
| 81 |
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
| 82 |
+
|
| 83 |
+
**Use** means anything you do with the software requiring one of your licenses.
|
README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AnimoFlow Demo
|
| 3 |
+
emoji: 🎭
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.31.0
|
| 8 |
+
hardware: zero-a10g
|
| 9 |
+
python_version: "3.11"
|
| 10 |
+
pinned: false
|
| 11 |
+
license: other
|
| 12 |
+
license_name: animoflow-community-license-1.0.0
|
| 13 |
+
license_link: LICENSE
|
| 14 |
+
short_description: Text → Motion → FBX/GLB. MDM diffusion + Blender retarget.
|
| 15 |
+
hf_oauth: true
|
| 16 |
+
hf_oauth_expiration_minutes: 480
|
| 17 |
+
preload_from_hub:
|
| 18 |
+
- "Qwen/Qwen2.5-1.5B-Instruct"
|
| 19 |
+
- "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# animoflow-app
|
| 23 |
+
|
| 24 |
+
Deployment wrapper for [AnimoFlow](https://github.com/AnimoFlow). Same image runs as a
|
| 25 |
+
[Hugging Face Space ZeroGPU](https://huggingface.co/docs/hub/en/spaces-zerogpu)
|
| 26 |
+
demo and as a one-command OSS local server.
|
| 27 |
+
|
| 28 |
+
The app is a thin layer on top of three sibling repos. None of them are forked
|
| 29 |
+
or modified — each is `git clone`d at Docker build time, pinned by SHA:
|
| 30 |
+
|
| 31 |
+
- [`comfyui-animoflow`](https://github.com/AnimoFlow/comfyui-animoflow) — model wrappers, IK + retarget code, Blender scripts
|
| 32 |
+
- [`animoflow-api`](https://github.com/AnimoFlow/animoflow-api) — `/v1/jobs` async FastAPI, validation models, task catalog
|
| 33 |
+
- upstream model repos — [MDM](https://github.com/GuyTevet/motion-diffusion-model), [momask-codes](https://github.com/EricGuo5513/momask-codes), more added per iteration
|
| 34 |
+
|
| 35 |
+
More documentation: the [AnimoFlow user guide](https://animoflow-alpha.pages.dev/guide/) covers all the ways to use the platform; all repositories are under [github.com/AnimoFlow](https://github.com/AnimoFlow).
|
| 36 |
+
|
| 37 |
+
Net new code in this repo: the `Dockerfile`, `entrypoint.sh`, `app.py` (mounts
|
| 38 |
+
Gradio on the imported animoflow-api FastAPI), `ui.py` (Gradio Blocks), and
|
| 39 |
+
`pipeline_hf.py` (replaces ComfyUI orchestration with in-process pipeline so
|
| 40 |
+
ZeroGPU's `@spaces.GPU` works).
|
| 41 |
+
|
| 42 |
+
## Architecture
|
| 43 |
+
|
| 44 |
+
Short version:
|
| 45 |
+
|
| 46 |
+
- **Single venv on HF.** Orchestrator + warm MDM-family models loaded at module
|
| 47 |
+
level. ZeroGPU forks the orchestrator process at each `@spaces.GPU` call; the
|
| 48 |
+
fork inherits warm models via copy-on-write, runs inference on real H200
|
| 49 |
+
for ~10–15 s, dies, releases GPU.
|
| 50 |
+
- **GPU only on inference.** Resample, IK (momask `Joint2BVHConvertor`), foot/
|
| 51 |
+
outlier fix, Blender retarget → FBX, Blender FBX → GLB all run CPU-side in the
|
| 52 |
+
persistent orchestrator process — no GPU billing.
|
| 53 |
+
- **Escape hatch for outlier models.** Models whose deps don't merge with the
|
| 54 |
+
orchestrator venv (Kimodo's NVIDIA stack, future Tier-2 models) live in
|
| 55 |
+
`/opt/venvs/comfy-X/` and are subprocess-called from inside `@spaces.GPU`
|
| 56 |
+
only when their model is selected. Cold-start tax (~15 s) accepted in
|
| 57 |
+
exchange for full dep isolation.
|
| 58 |
+
|
| 59 |
+
## API
|
| 60 |
+
|
| 61 |
+
Same `/v1/*` surface as the rest of AnimoFlow:
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
POST /v1/jobs { input: {type, prompt}, model, character, duration, seed }
|
| 65 |
+
GET /v1/jobs/{id} { status, progress, stage, download_url, error, ... }
|
| 66 |
+
GET /v1/files/{name} binary FBX or GLB
|
| 67 |
+
GET /v1/tasks { tasks: [...] }
|
| 68 |
+
GET /v1/models { stages: [...] }
|
| 69 |
+
GET /v1/characters { characters: [...] }
|
| 70 |
+
GET /v1/health { status: "ok" }
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Anything Gradio can do, you can also do via `curl`, `gradio_client`,
|
| 74 |
+
`@gradio/client` JS, or the Blender add-on.
|
| 75 |
+
|
| 76 |
+
## Run locally (no HF account, no Docker)
|
| 77 |
+
|
| 78 |
+
Requires Python 3.11, Blender 4.2 LTS on PATH (or `BLENDER_BIN` set), and
|
| 79 |
+
the sibling repos cloned alongside.
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
git clone https://github.com/AnimoFlow/comfyui-animoflow ../comfyui-animoflow
|
| 83 |
+
git clone https://github.com/AnimoFlow/animoflow-api ../animoflow-api
|
| 84 |
+
git clone https://github.com/GuyTevet/motion-diffusion-model ../mdm-codes
|
| 85 |
+
git clone https://github.com/EricGuo5513/momask-codes ../momask-codes
|
| 86 |
+
|
| 87 |
+
cd animoflow-app
|
| 88 |
+
python -m venv .venv
|
| 89 |
+
.venv/bin/pip install -r requirements.txt
|
| 90 |
+
|
| 91 |
+
# Point at your local clones
|
| 92 |
+
cp .env.example .env
|
| 93 |
+
$EDITOR .env # set CHECKPOINTS_DIR, CHARACTERS_DIR, and ANIMOFLOW_API_API_DIR (path to ../animoflow-api/api)
|
| 94 |
+
|
| 95 |
+
# Download MDM checkpoints
|
| 96 |
+
./scripts/download_weights.sh
|
| 97 |
+
|
| 98 |
+
.venv/bin/python app.py
|
| 99 |
+
# → http://localhost:7860
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## Run locally (Docker)
|
| 103 |
+
|
| 104 |
+
The Dockerfile uses two [BuildKit secrets](https://docs.docker.com/build/building/secrets/), neither
|
| 105 |
+
of which lands in any image layer:
|
| 106 |
+
|
| 107 |
+
- `gh_token` — read access to `AnimoFlow/{comfyui-animoflow,animoflow-api}` (only needed while the repos are private)
|
| 108 |
+
- `hf_token` — read access to BOTH `AnimoFlow/animoflow-checkpoints` (private HF Hub model repo where MDM weights live) AND `AnimoFlow/character-assets` (private repo with the Mixamo character FBXs, split out of the checkpoints repo 2026-07-06)
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
# 1. Drop tokens into files (gh + hf CLIs extract them if you're logged in)
|
| 112 |
+
gh auth token > /tmp/gh_token
|
| 113 |
+
huggingface-cli auth token > /tmp/hf_token # or: cat ~/.cache/huggingface/token > /tmp/hf_token
|
| 114 |
+
|
| 115 |
+
# 2. Build with both secrets mounted
|
| 116 |
+
DOCKER_BUILDKIT=1 docker build \
|
| 117 |
+
--secret id=gh_token,src=/tmp/gh_token \
|
| 118 |
+
--secret id=hf_token,src=/tmp/hf_token \
|
| 119 |
+
-t animoflow/app .
|
| 120 |
+
|
| 121 |
+
# 3. Clean up
|
| 122 |
+
rm /tmp/gh_token /tmp/hf_token
|
| 123 |
+
|
| 124 |
+
# 4. Run — checkpoints are already baked in, no extra mounts needed
|
| 125 |
+
docker run --rm -p 7860:7860 animoflow/app
|
| 126 |
+
# → http://localhost:7860
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
The Dockerfile is arch-aware (Blender 4.2 LTS for both `linux/amd64` and
|
| 130 |
+
`linux/arm64`). On Apple Silicon Macs, native arm64 builds work — but if
|
| 131 |
+
you want byte-parity with HF's amd64 ZeroGPU hosts, force the platform:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
| 135 |
+
--secret id=gh_token,src=/tmp/gh_token \
|
| 136 |
+
--secret id=hf_token,src=/tmp/hf_token \
|
| 137 |
+
-t animoflow/app .
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
This is the same image deployed to HF Spaces. `@spaces.GPU` no-ops when
|
| 141 |
+
`spaces` isn't on the PYTHONPATH (or when `SPACE_ID` isn't set), so locally
|
| 142 |
+
you get whatever GPU the host has — or CPU fallback.
|
| 143 |
+
|
| 144 |
+
## Run on HF Spaces
|
| 145 |
+
|
| 146 |
+
This repo's `README.md` has the Space card YAML at the top. To deploy:
|
| 147 |
+
|
| 148 |
+
1. Create a Space at <https://huggingface.co/new-space> — pick the `Gradio`
|
| 149 |
+
SDK, `ZeroGPU` hardware, and the visibility you want.
|
| 150 |
+
2. **Configure two Space secrets** under Settings → Variables and secrets:
|
| 151 |
+
- `gh_token` — fine-grained GitHub PAT with read access to `AnimoFlow/animoflow-api` and `AnimoFlow/comfyui-animoflow`
|
| 152 |
+
- `hf_token` — HF token with read access to BOTH `AnimoFlow/animoflow-checkpoints` AND `AnimoFlow/character-assets` (fine-grained tokens must list both repos explicitly — a token missing the character-assets scope crashes bootstrap with RepositoryNotFoundError)
|
| 153 |
+
|
| 154 |
+
HF makes Space secrets available to BuildKit's `--mount=type=secret`
|
| 155 |
+
automatically, so the Dockerfile picks them up without further config.
|
| 156 |
+
3. Push:
|
| 157 |
+
```bash
|
| 158 |
+
git remote add space https://huggingface.co/spaces/AnimoFlow/animoflow-demo
|
| 159 |
+
git push space main
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
HF builds the Dockerfile (cloning the private repos via `gh_token`,
|
| 163 |
+
baking the MDM checkpoints via `hf_token`), allocates ZeroGPU on each
|
| 164 |
+
request, and serves the Gradio UI at the Space URL.
|
| 165 |
+
|
| 166 |
+
**Note on file downloads:** On HF Spaces, generated FBX/GLB files are
|
| 167 |
+
served through Gradio's UI (the `gr.Model3D` widget renders them directly).
|
| 168 |
+
External clients should use `gradio_client` to fetch results. The `/v1/files`
|
| 169 |
+
REST endpoint works locally and in OSS Docker mode but is mangled by
|
| 170 |
+
Gradio's reverse proxy on HF Spaces — use the Gradio client API instead.
|
| 171 |
+
|
| 172 |
+
## Repo layout
|
| 173 |
+
|
| 174 |
+
```
|
| 175 |
+
animoflow-app/
|
| 176 |
+
├── Dockerfile # multi-arg build, Blender pinned tarball
|
| 177 |
+
├── entrypoint.sh # downloads weights → starts uvicorn
|
| 178 |
+
├── requirements.txt # orchestrator + MDM family
|
| 179 |
+
├── app.py # mounts Gradio on imported animoflow-api FastAPI
|
| 180 |
+
├── ui.py # Gradio Blocks
|
| 181 |
+
├── pipeline_hf.py # GPU-decorated inference + CPU pipeline
|
| 182 |
+
├── bootstrap.py # build/startup: repos, weights, characters, Blender
|
| 183 |
+
├── spaces_compat.py # @spaces.GPU graceful fallback
|
| 184 |
+
├── animoflow_models/
|
| 185 |
+
│ ├── __init__.py
|
| 186 |
+
│ └── registry.py # warm-loaded MDM family at module level
|
| 187 |
+
├── escape_hatch/
|
| 188 |
+
│ ├── __init__.py
|
| 189 |
+
│ └── invoke.py # subprocess into /opt/venvs/comfy-X
|
| 190 |
+
├── scripts/
|
| 191 |
+
│ ├── download_weights.sh # idempotent weight download
|
| 192 |
+
│ └── run_inference_kimodo.py # escape-hatch subprocess runner (kimodo)
|
| 193 |
+
├── tests/
|
| 194 |
+
│ ├── conftest.py
|
| 195 |
+
│ ├── test_app_imports.py # FastAPI mounts, no model deps required
|
| 196 |
+
│ ├── test_pipeline_hf.py # mocked GPU + real CPU pipeline
|
| 197 |
+
│ └── test_spaces_compat.py # decorator behavior
|
| 198 |
+
├── .env.example
|
| 199 |
+
├── .gitignore
|
| 200 |
+
├── .dockerignore
|
| 201 |
+
└── LICENSE # AnimoFlow Community License 1.0.0
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
## License
|
| 205 |
+
|
| 206 |
+
**AnimoFlow Community License 1.0.0** (see [`LICENSE`](LICENSE)) — the same
|
| 207 |
+
source-available, perpetual threshold license as `animoflow-api`: free
|
| 208 |
+
forever for individuals, non-profits, academia, and organizations under
|
| 209 |
+
$5M USD (2026) total finances & 100 people; commercial license above
|
| 210 |
+
(guy@animoflow.ai). At runtime this wrapper fetches components that carry
|
| 211 |
+
their own licenses — `comfyui-animoflow` is AGPL-3.0, `animoflow-api` is
|
| 212 |
+
AFCL; see [AnimoFlow/legal](https://github.com/AnimoFlow/legal) for the
|
| 213 |
+
full per-repo map.
|
| 214 |
+
|
| 215 |
+
### Third-party model licenses
|
| 216 |
+
|
| 217 |
+
The Space loads inference code and weights from upstream projects with their
|
| 218 |
+
own terms. Bundling them does not change this wrapper's AnimoFlow
|
| 219 |
+
Community License declaration, but downstream users should observe each
|
| 220 |
+
model's license:
|
| 221 |
+
|
| 222 |
+
- **MDM** (Motion Diffusion Model) — code: [MIT](https://github.com/GuyTevet/motion-diffusion-model);
|
| 223 |
+
HumanML3D-trained checkpoint published by the authors.
|
| 224 |
+
- **MoMask** — code: [MIT](https://github.com/EricGuo5513/momask-codes);
|
| 225 |
+
HumanML3D-trained checkpoint with the dataset's research-use terms.
|
| 226 |
+
- **Kimodo (NVIDIA)** — code: Apache-2.0
|
| 227 |
+
([nv-tlabs/kimodo](https://github.com/nv-tlabs/kimodo)). Weights:
|
| 228 |
+
`Kimodo-SOMA-RP-v1` ships under the NVIDIA Open Model License
|
| 229 |
+
(commercial use permitted). The Space deliberately uses ONLY the
|
| 230 |
+
commercial-OK variants; the research-only `Kimodo-SMPLX-RP-v1` is not loaded.
|
| 231 |
+
- **LLaMA-3-8B-Instruct** (text encoder used by Kimodo) — pulled from the
|
| 232 |
+
ungated `NousResearch/Meta-Llama-3-8B-Instruct` mirror. Use is governed by
|
| 233 |
+
Meta's [LLaMA-3 Community License](https://llama.meta.com/llama3/license/).
|
| 234 |
+
- **SOMA-X** ([NVlabs/SOMA-X](https://github.com/NVlabs/SOMA-X)) — used by
|
| 235 |
+
Kimodo for SOMA→SMPL mesh skinning. See the upstream repo for terms.
|
SECURITY.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Reporting a vulnerability
|
| 4 |
+
|
| 5 |
+
Please report security issues privately to **guy@animoflow.ai** — do not
|
| 6 |
+
open a public issue. You'll get an acknowledgment within a few days.
|
| 7 |
+
|
| 8 |
+
## Scope notes
|
| 9 |
+
|
| 10 |
+
- This code runs the hosted demo at animoflow.ai / the AnimoFlow Space.
|
| 11 |
+
Issues in the API contract itself are best reported against
|
| 12 |
+
animoflow-api, but reports here reach the same maintainer.
|
| 13 |
+
- The Space executes no code from prompts or uploads; generated animation
|
| 14 |
+
leaves as data files (FBX/GLB) only.
|
| 15 |
+
- Prompts are logged for service health; no accounts or PII are stored.
|
| 16 |
+
|
| 17 |
+
There is no bug bounty program at this time.
|
animoflow_models/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model registry for animoflow-app.
|
| 3 |
+
|
| 4 |
+
Imports the upstream model `inference.py` modules into the orchestrator
|
| 5 |
+
process at module level (per ZeroGPU best practice). Each model becomes
|
| 6 |
+
warm and is inherited by the @spaces.GPU fork via copy-on-write — no
|
| 7 |
+
per-request load cost.
|
| 8 |
+
|
| 9 |
+
Outlier models (Kimodo etc. — separate dep tree, won't merge here) live
|
| 10 |
+
in escape_hatch/ and are subprocess-called only when their model is
|
| 11 |
+
selected.
|
| 12 |
+
|
| 13 |
+
Carries MDM, priorMDM, and MoMask.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from .registry import generate, list_models, model_status
|
| 17 |
+
|
| 18 |
+
__all__ = ["generate", "list_models", "model_status"]
|
animoflow_models/registry.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warm-model registry: imports upstream model inference modules into the
|
| 3 |
+
orchestrator process at module level so the @spaces.GPU fork inherits
|
| 4 |
+
them via copy-on-write.
|
| 5 |
+
|
| 6 |
+
Each model wrapper lives in `comfyui-animoflow/containers/<model>/` and
|
| 7 |
+
exposes a class (e.g. `MDMInference`) with a `.generate(prompt, …) ->
|
| 8 |
+
(npz_bytes, metadata)` method. We don't modify any of that — we just
|
| 9 |
+
import it and hold a singleton.
|
| 10 |
+
|
| 11 |
+
If a model fails to import (missing weights, dep conflict surfacing
|
| 12 |
+
later, etc.), it's logged and removed from the active set — the API
|
| 13 |
+
will report it as unavailable instead of crashing the whole orchestrator.
|
| 14 |
+
|
| 15 |
+
Per [[Wrap, don't fork upstream model repos]] and [[animoflow-app — HF +
|
| 16 |
+
OSS deployment wrapper]].
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import importlib
|
| 22 |
+
import logging
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
+
import threading
|
| 26 |
+
from contextlib import contextmanager
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Any, Callable
|
| 29 |
+
|
| 30 |
+
log = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Model definitions — declarative.
|
| 34 |
+
# Each entry says where the wrapper module lives, which class to instantiate,
|
| 35 |
+
# and what the .generate() signature looks like (so we can adapt args).
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
_COMFYUI_ANIMOFLOW = Path(
|
| 39 |
+
os.environ.get("COMFYUI_ANIMOFLOW_DIR", "/opt/comfyui-animoflow")
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_DEFINITIONS: dict[str, dict[str, Any]] = {
|
| 43 |
+
"mdm": {
|
| 44 |
+
"container_dir": _COMFYUI_ANIMOFLOW / "containers" / "mdm",
|
| 45 |
+
"module_name": "inference",
|
| 46 |
+
"class_name": "MDMInference",
|
| 47 |
+
# MDMInference.generate(prompt, num_frames, seed, guidance_param) -> (bytes, dict)
|
| 48 |
+
"generate_kwargs": {
|
| 49 |
+
"guidance_param_default": 7.5,
|
| 50 |
+
"guidance_param_name": "guidance_param",
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
"momask": {
|
| 54 |
+
"container_dir": _COMFYUI_ANIMOFLOW / "containers" / "momask",
|
| 55 |
+
"module_name": "inference_momask",
|
| 56 |
+
"class_name": "MoMaskInference",
|
| 57 |
+
# MoMaskInference.generate uses cond_scale (not guidance_param) and
|
| 58 |
+
# reads CHECKPOINTS_DIR at module import time, so we override the env
|
| 59 |
+
# to point at the momask/ subdir (populated by bootstrap snapshot
|
| 60 |
+
# download from AnimoFlow/animoflow-checkpoints `momask/t2m/**`).
|
| 61 |
+
"generate_kwargs": {
|
| 62 |
+
"guidance_param_default": 5.0,
|
| 63 |
+
"guidance_param_name": "cond_scale",
|
| 64 |
+
},
|
| 65 |
+
"env_overrides_factory": lambda: {
|
| 66 |
+
"CHECKPOINTS_DIR": os.path.join(
|
| 67 |
+
os.environ.get("CHECKPOINTS_DIR", "/app/checkpoints"), "momask"
|
| 68 |
+
),
|
| 69 |
+
},
|
| 70 |
+
},
|
| 71 |
+
"priormdm": {
|
| 72 |
+
"container_dir": _COMFYUI_ANIMOFLOW / "containers" / "priormdm",
|
| 73 |
+
"module_name": "inference",
|
| 74 |
+
"class_name": "PriorMDMInference",
|
| 75 |
+
# PriorMDMInference.generate(prompt, num_frames, seed, guidance_param,
|
| 76 |
+
# trajectory=, curve_2d=, accel_frac=, decel_frac=) — the extra
|
| 77 |
+
# kwargs are forwarded by pipeline_hf.run() when model=="priormdm".
|
| 78 |
+
# Upstream default cfg=2.5 (≠ MDM's 7.5). (The container also accepts
|
| 79 |
+
# `sample_id` for legacy HumanML3D-dataset references; retired from
|
| 80 |
+
# the public API on 2026-06-24 and no longer forwarded by the wrapper.)
|
| 81 |
+
"generate_kwargs": {
|
| 82 |
+
"guidance_param_default": 2.5,
|
| 83 |
+
"guidance_param_name": "guidance_param",
|
| 84 |
+
},
|
| 85 |
+
# inference.py reads PRIORMDM_PATH, WEIGHTS_DIR, CHECKPOINT_DIR,
|
| 86 |
+
# HML_DATASET_DIR at module-import time. We plant the first three;
|
| 87 |
+
# HML_DATASET_DIR stays unset — sample_id path raises a clean 503
|
| 88 |
+
# at request time, not at load time. PRIORMDM_PATH defaults to the
|
| 89 |
+
# bootstrap clone target under EXTERNAL_DIR.
|
| 90 |
+
"env_overrides_factory": lambda: {
|
| 91 |
+
"PRIORMDM_PATH": os.environ.get(
|
| 92 |
+
"PRIORMDM_PATH",
|
| 93 |
+
str(_COMFYUI_ANIMOFLOW.parent / "priormdm-codes"),
|
| 94 |
+
),
|
| 95 |
+
"WEIGHTS_DIR": os.path.join(
|
| 96 |
+
os.environ.get("CHECKPOINTS_DIR", "/app/checkpoints"),
|
| 97 |
+
"priormdm",
|
| 98 |
+
),
|
| 99 |
+
"CHECKPOINT_DIR": os.path.join(
|
| 100 |
+
os.environ.get("CHECKPOINTS_DIR", "/app/checkpoints"),
|
| 101 |
+
"priormdm",
|
| 102 |
+
),
|
| 103 |
+
},
|
| 104 |
+
},
|
| 105 |
+
# Kimodo lives in escape_hatch/ because of its NVIDIA stack.
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# Singletons keyed by model name; populated lazily on first .generate().
|
| 109 |
+
# Once loaded, the model stays warm in process memory.
|
| 110 |
+
_INSTANCES: dict[str, Any] = {}
|
| 111 |
+
_LOAD_LOCK = threading.Lock()
|
| 112 |
+
_LOAD_FAILED: dict[str, str] = {} # model → error message
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@contextmanager
|
| 116 |
+
def _isolated_sys_path(extra: Path):
|
| 117 |
+
"""Temporarily prepend `extra` to sys.path. Restores on exit so we don't
|
| 118 |
+
leak a sibling-module on the path that could shadow other models'
|
| 119 |
+
imports."""
|
| 120 |
+
extra_str = str(extra)
|
| 121 |
+
sys.path.insert(0, extra_str)
|
| 122 |
+
try:
|
| 123 |
+
yield
|
| 124 |
+
finally:
|
| 125 |
+
try:
|
| 126 |
+
sys.path.remove(extra_str)
|
| 127 |
+
except ValueError:
|
| 128 |
+
pass
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# Top-level package names that BOTH MDM and MoMask vendor at the root of their
|
| 132 |
+
# respective repos (e.g. `utils/`, `model/`). If MoMask is imported first, its
|
| 133 |
+
# `utils` package gets cached in sys.modules — and a subsequent MDM import
|
| 134 |
+
# then sees MoMask's `utils` (no `config` submodule) and silently fails.
|
| 135 |
+
# Pre-load cleanup is the simplest fix that doesn't require subprocess
|
| 136 |
+
# isolation (and keeps failures loud — no silent fallback).
|
| 137 |
+
_SHADOWING_NAMESPACES = (
|
| 138 |
+
"utils",
|
| 139 |
+
"model",
|
| 140 |
+
"diffusion",
|
| 141 |
+
"data_loaders",
|
| 142 |
+
"data_loader",
|
| 143 |
+
"options",
|
| 144 |
+
"common",
|
| 145 |
+
"networks",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _evict_shadowing_modules() -> None:
|
| 150 |
+
"""Pop modules whose names collide across our wrapped upstream repos.
|
| 151 |
+
|
| 152 |
+
Called BEFORE each model load so the next model's top-level imports
|
| 153 |
+
resolve against its own MDM_PATH / MOMASK_PATH directory, not against
|
| 154 |
+
whatever the previous model left in sys.modules.
|
| 155 |
+
"""
|
| 156 |
+
evicted: list[str] = []
|
| 157 |
+
for ns in _SHADOWING_NAMESPACES:
|
| 158 |
+
# Pop the namespace itself and any cached submodules under it.
|
| 159 |
+
for key in [k for k in list(sys.modules) if k == ns or k.startswith(ns + ".")]:
|
| 160 |
+
sys.modules.pop(key, None)
|
| 161 |
+
evicted.append(key)
|
| 162 |
+
if evicted:
|
| 163 |
+
log.info("evicted %d shadowing modules from sys.modules: %s",
|
| 164 |
+
len(evicted), ", ".join(sorted(evicted)[:8]) + ("…" if len(evicted) > 8 else ""))
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _load(name: str) -> Any:
|
| 168 |
+
"""Load an upstream wrapper module and instantiate its inference class.
|
| 169 |
+
|
| 170 |
+
Lazy + thread-safe + memoized. Returns the singleton or raises
|
| 171 |
+
RuntimeError with a hopefully-actionable message.
|
| 172 |
+
"""
|
| 173 |
+
with _LOAD_LOCK:
|
| 174 |
+
if name in _INSTANCES:
|
| 175 |
+
return _INSTANCES[name]
|
| 176 |
+
if name in _LOAD_FAILED:
|
| 177 |
+
raise RuntimeError(
|
| 178 |
+
f"Model '{name}' previously failed to load: {_LOAD_FAILED[name]}"
|
| 179 |
+
)
|
| 180 |
+
if name not in _DEFINITIONS:
|
| 181 |
+
raise RuntimeError(
|
| 182 |
+
f"Unknown model '{name}'. Known: {sorted(_DEFINITIONS.keys())}. "
|
| 183 |
+
"Outlier models (Kimodo, etc.) are handled by escape_hatch/, not this registry."
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
spec = _DEFINITIONS[name]
|
| 187 |
+
container_dir: Path = spec["container_dir"]
|
| 188 |
+
if not container_dir.is_dir():
|
| 189 |
+
msg = (
|
| 190 |
+
f"Container dir missing for '{name}': {container_dir}. "
|
| 191 |
+
"Was the comfyui-animoflow clone created at Docker build time?"
|
| 192 |
+
)
|
| 193 |
+
_LOAD_FAILED[name] = msg
|
| 194 |
+
raise RuntimeError(msg)
|
| 195 |
+
|
| 196 |
+
# Determine device. Module-level loading goes on CPU (or ZeroGPU
|
| 197 |
+
# emulation); the @spaces.GPU fork will materialize tensors to real
|
| 198 |
+
# GPU at call time.
|
| 199 |
+
device = "cpu"
|
| 200 |
+
try:
|
| 201 |
+
import torch
|
| 202 |
+
|
| 203 |
+
if torch.cuda.is_available():
|
| 204 |
+
device = "cuda"
|
| 205 |
+
except ImportError:
|
| 206 |
+
pass # torch not installed in test env — fine, model load will fail later
|
| 207 |
+
|
| 208 |
+
log.info("Loading model %r from %s (device=%s)", name, container_dir, device)
|
| 209 |
+
# Install numpy compat shim BEFORE any model load. MDM (af061ca) uses
|
| 210 |
+
# `np.float` which was removed in numpy 1.24; momask-codes uses
|
| 211 |
+
# `np.core.umath_tests.inner1d` removed in numpy 2.0. The shim lives
|
| 212 |
+
# in pipeline_hf because it's also needed by the retargeter, but it
|
| 213 |
+
# must fire BEFORE inference too — not just at retarget time, when
|
| 214 |
+
# MDM's `_load_model` has already crashed. Idempotent.
|
| 215 |
+
from pipeline_hf import _install_numpy_compat_shim
|
| 216 |
+
_install_numpy_compat_shim()
|
| 217 |
+
# Evict any shadowing top-level packages cached from a previously-loaded
|
| 218 |
+
# model's import (e.g. MoMask's `utils` shadowing MDM's `utils`). This
|
| 219 |
+
# is what fixes the "[MDM] model load failed (No module named
|
| 220 |
+
# 'utils.config')" placeholder regression diagnosed 2026-06-08.
|
| 221 |
+
_evict_shadowing_modules()
|
| 222 |
+
# Apply per-model env overrides (e.g. MoMask needs CHECKPOINTS_DIR
|
| 223 |
+
# to point at its own subdir). Reads happen at module import time,
|
| 224 |
+
# so set BEFORE importlib.import_module. Save originals so other
|
| 225 |
+
# models (loaded later) see their own values.
|
| 226 |
+
env_factory = spec.get("env_overrides_factory")
|
| 227 |
+
env_saved: dict[str, str | None] = {}
|
| 228 |
+
if env_factory:
|
| 229 |
+
for k, v in env_factory().items():
|
| 230 |
+
env_saved[k] = os.environ.get(k)
|
| 231 |
+
os.environ[k] = v
|
| 232 |
+
log.info(" env override: %s=%s", k, v)
|
| 233 |
+
try:
|
| 234 |
+
try:
|
| 235 |
+
# Keep container_dir on sys.path PERMANENTLY (in addition to
|
| 236 |
+
# the temporary insertion below). Some wrappers reference
|
| 237 |
+
# container-local helper modules from inside their methods
|
| 238 |
+
# — e.g. priormdm/inference.py:generate() does
|
| 239 |
+
# `from trajectory_utils import bake_trajectory` lazily.
|
| 240 |
+
# If container_dir gets popped post-load, that import 500s
|
| 241 |
+
# at generate-time. This mirrors how MDM/MoMask wrappers
|
| 242 |
+
# do `sys.path.insert(0, MDM_PATH)` at module-import time
|
| 243 |
+
# and never remove it.
|
| 244 |
+
container_dir_str = str(container_dir)
|
| 245 |
+
if container_dir_str not in sys.path:
|
| 246 |
+
sys.path.append(container_dir_str)
|
| 247 |
+
with _isolated_sys_path(container_dir):
|
| 248 |
+
# Re-import the wrapper's module fresh in case another
|
| 249 |
+
# model already imported a same-named module.
|
| 250 |
+
module_name = spec["module_name"]
|
| 251 |
+
saved_module = sys.modules.pop(module_name, None)
|
| 252 |
+
try:
|
| 253 |
+
mod = importlib.import_module(module_name)
|
| 254 |
+
klass = getattr(mod, spec["class_name"])
|
| 255 |
+
inst = klass(device=device)
|
| 256 |
+
finally:
|
| 257 |
+
sys.modules.pop(module_name, None)
|
| 258 |
+
if saved_module is not None:
|
| 259 |
+
sys.modules[module_name] = saved_module
|
| 260 |
+
finally:
|
| 261 |
+
# Restore env even if loading raised.
|
| 262 |
+
for k, original in env_saved.items():
|
| 263 |
+
if original is None:
|
| 264 |
+
os.environ.pop(k, None)
|
| 265 |
+
else:
|
| 266 |
+
os.environ[k] = original
|
| 267 |
+
_INSTANCES[name] = inst
|
| 268 |
+
log.info("Loaded %r: %s", name, type(inst).__name__)
|
| 269 |
+
return inst
|
| 270 |
+
except Exception as exc: # noqa: BLE001 (we want to log the full error)
|
| 271 |
+
msg = f"{type(exc).__name__}: {exc}"
|
| 272 |
+
_LOAD_FAILED[name] = msg
|
| 273 |
+
log.exception("Failed to load model %r", name)
|
| 274 |
+
raise RuntimeError(f"Model '{name}' failed to load: {msg}") from exc
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ---------------------------------------------------------------------------
|
| 278 |
+
# Public API
|
| 279 |
+
# ---------------------------------------------------------------------------
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def list_models() -> list[str]:
|
| 283 |
+
"""Names of registered models (whether loaded or not)."""
|
| 284 |
+
return sorted(_DEFINITIONS.keys())
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def model_status() -> dict[str, str]:
|
| 288 |
+
"""Per-model status: 'loaded' | 'failed' | 'pending'."""
|
| 289 |
+
out: dict[str, str] = {}
|
| 290 |
+
for name in _DEFINITIONS:
|
| 291 |
+
if name in _INSTANCES:
|
| 292 |
+
out[name] = "loaded"
|
| 293 |
+
elif name in _LOAD_FAILED:
|
| 294 |
+
out[name] = "failed"
|
| 295 |
+
else:
|
| 296 |
+
out[name] = "pending"
|
| 297 |
+
return out
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def generate(
|
| 301 |
+
name: str,
|
| 302 |
+
prompt: str,
|
| 303 |
+
num_frames: int,
|
| 304 |
+
seed: int,
|
| 305 |
+
*,
|
| 306 |
+
guidance_param: float | None = None,
|
| 307 |
+
**extra: Any,
|
| 308 |
+
) -> tuple[bytes, dict]:
|
| 309 |
+
"""Run the model's .generate() and return (npz_bytes, metadata).
|
| 310 |
+
|
| 311 |
+
Loads the model on first call (lazy). Subsequent calls reuse the
|
| 312 |
+
in-memory instance.
|
| 313 |
+
|
| 314 |
+
Inside @spaces.GPU on HF, this runs on real GPU. Outside HF, runs on
|
| 315 |
+
whatever device torch chose at load time.
|
| 316 |
+
"""
|
| 317 |
+
inst = _load(name)
|
| 318 |
+
spec = _DEFINITIONS[name]
|
| 319 |
+
gen_cfg = spec.get("generate_kwargs", {})
|
| 320 |
+
kwargs: dict[str, Any] = {
|
| 321 |
+
"prompt": prompt,
|
| 322 |
+
"num_frames": num_frames,
|
| 323 |
+
"seed": seed,
|
| 324 |
+
}
|
| 325 |
+
if guidance_param is None:
|
| 326 |
+
guidance_param = gen_cfg.get("guidance_param_default", 7.5)
|
| 327 |
+
# Each model uses its own kwarg name (MDM: guidance_param,
|
| 328 |
+
# MoMask: cond_scale). MoMask raises on unknown kwargs.
|
| 329 |
+
param_name = gen_cfg.get("guidance_param_name", "guidance_param")
|
| 330 |
+
kwargs[param_name] = guidance_param
|
| 331 |
+
kwargs.update(extra)
|
| 332 |
+
|
| 333 |
+
npz_bytes, metadata = inst.generate(**kwargs)
|
| 334 |
+
return npz_bytes, metadata
|
app.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
animoflow-app — orchestrator entry point.
|
| 3 |
+
|
| 4 |
+
Mounts a Gradio UI on top of the imported animoflow-api FastAPI app, then
|
| 5 |
+
monkeypatches animoflow-api's `pipeline.run` / `pipeline.run_timeline` with
|
| 6 |
+
our HF-flavored pipeline that calls the GPU-decorated inference function
|
| 7 |
+
in-process and runs all post-processing CPU-side in the orchestrator.
|
| 8 |
+
|
| 9 |
+
NOTHING in animoflow-api/api/main.py or animoflow-api/api/pipeline.py is
|
| 10 |
+
modified on disk. The wrap-don't-modify rule extends transitively to our
|
| 11 |
+
own repos here.
|
| 12 |
+
|
| 13 |
+
Order of operations matters:
|
| 14 |
+
1. sys.path inject /opt/animoflow-api/api so its sibling-module imports
|
| 15 |
+
(`import config`, `import job_store`, `from auth import …`) resolve.
|
| 16 |
+
2. Import the `pipeline` module FROM that path.
|
| 17 |
+
3. Replace pipeline.run / pipeline.run_timeline with our pipeline_hf
|
| 18 |
+
versions. Functions are looked up at call time inside
|
| 19 |
+
animoflow-api/api/main.py:_run_pipeline, so monkeypatching after import
|
| 20 |
+
is enough.
|
| 21 |
+
4. Import animoflow-api's `main` (which pulls in pipeline by name).
|
| 22 |
+
5. Build Gradio Blocks and mount them on the FastAPI app at "/".
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import logging
|
| 28 |
+
import os
|
| 29 |
+
import sys
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
logging.basicConfig(
|
| 33 |
+
level=os.environ.get("LOG_LEVEL", "INFO"),
|
| 34 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 35 |
+
)
|
| 36 |
+
log = logging.getLogger("animoflow-app")
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Step -2: ensure OUTPUT_DIR is consistent across all modules.
|
| 40 |
+
#
|
| 41 |
+
# pipeline_hf defaults to /tmp/animoflow-output; animoflow-api/api/config.py
|
| 42 |
+
# defaults to a home-directory path. On HF Spaces (Gradio SDK, no Dockerfile ENV),
|
| 43 |
+
# the env var may not be set, so the two defaults diverge and /v1/files/
|
| 44 |
+
# returns 404 (config.OUTPUT_DIR points at a dir the pipeline never wrote to).
|
| 45 |
+
# Fix: plant the env var before any animoflow-api import.
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
os.environ.setdefault("OUTPUT_DIR", "/tmp/animoflow-output")
|
| 49 |
+
|
| 50 |
+
# HF Space's xet protocol fails with "Permission denied (os error 13)" trying
|
| 51 |
+
# to write its cache logs to /home/user/.cache/huggingface/xet/. Bootstrapping
|
| 52 |
+
# huggingface_hub via the legacy LFS-style download avoids the issue. Surfaced
|
| 53 |
+
# when sentence-transformers landed in requirements.txt and pip resolved
|
| 54 |
+
# transformers up to 5.12 which uses xet by default.
|
| 55 |
+
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
# Step -1: bootstrap external repos + checkpoints when running on HF Gradio
|
| 59 |
+
# Spaces (which have no Docker build phase). No-op in Docker mode.
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
# Make sure our own dir is on sys.path so `import bootstrap` works regardless
|
| 63 |
+
# of how the process was launched.
|
| 64 |
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 65 |
+
if _THIS_DIR not in sys.path:
|
| 66 |
+
sys.path.insert(0, _THIS_DIR)
|
| 67 |
+
|
| 68 |
+
import bootstrap as _bootstrap # noqa: E402
|
| 69 |
+
|
| 70 |
+
_bootstrap.bootstrap_external_repos()
|
| 71 |
+
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
# Step 0: auto-detect Blender for the rig + GLB stages.
|
| 74 |
+
# `MotionRetargeter.bvh_to_fbx` reads $BLENDER_BIN at module load. If it's
|
| 75 |
+
# not set (e.g. a fresh OSS-local Mac dev), we plant the right value here
|
| 76 |
+
# before any retargeter import. The pipeline_hf._find_blender() helper
|
| 77 |
+
# uses the same priority order.
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
import shutil as _shutil
|
| 81 |
+
from pathlib import Path as _Path
|
| 82 |
+
|
| 83 |
+
if not os.environ.get("BLENDER_BIN", "").strip():
|
| 84 |
+
for _candidate in (
|
| 85 |
+
_shutil.which("blender"),
|
| 86 |
+
"/Applications/Blender.app/Contents/MacOS/Blender",
|
| 87 |
+
"/opt/blender/blender",
|
| 88 |
+
):
|
| 89 |
+
if _candidate and _Path(_candidate).is_file():
|
| 90 |
+
os.environ["BLENDER_BIN"] = _candidate
|
| 91 |
+
log.info("Auto-detected Blender at %s", _candidate)
|
| 92 |
+
break
|
| 93 |
+
else:
|
| 94 |
+
log.warning(
|
| 95 |
+
"Blender not found on PATH or at common install locations. "
|
| 96 |
+
"Rig + GLB stages will fail. Set BLENDER_BIN to override."
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Step 1: locate animoflow-api and put its api/ on sys.path
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
_ANIMOFLOW_API_API = os.environ.get(
|
| 104 |
+
"ANIMOFLOW_API_API_DIR", "/opt/animoflow-api/api"
|
| 105 |
+
)
|
| 106 |
+
if not Path(_ANIMOFLOW_API_API).is_dir():
|
| 107 |
+
raise RuntimeError(
|
| 108 |
+
f"animoflow-api/api not found at {_ANIMOFLOW_API_API!r}. "
|
| 109 |
+
"Set ANIMOFLOW_API_API_DIR or rebuild the Docker image. "
|
| 110 |
+
"The wrapper repo must be cloned at Docker build time."
|
| 111 |
+
)
|
| 112 |
+
if _ANIMOFLOW_API_API not in sys.path:
|
| 113 |
+
sys.path.insert(0, _ANIMOFLOW_API_API)
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Step 2: locate animoflow-app's own code on sys.path so pipeline_hf etc.
|
| 117 |
+
# import cleanly when uvicorn is invoked from another cwd.
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
_ANIMOFLOW_APP = Path(__file__).resolve().parent
|
| 121 |
+
if str(_ANIMOFLOW_APP) not in sys.path:
|
| 122 |
+
sys.path.insert(0, str(_ANIMOFLOW_APP))
|
| 123 |
+
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
# Step 3: import animoflow-api's `pipeline` and replace its public functions
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
|
| 128 |
+
import pipeline as _animoflow_api_pipeline # noqa: E402 (animoflow-api's module)
|
| 129 |
+
import pipeline_hf # noqa: E402 (ours, replaces the ComfyUI-orchestrated path)
|
| 130 |
+
|
| 131 |
+
_animoflow_api_pipeline.run = pipeline_hf.run
|
| 132 |
+
_animoflow_api_pipeline.run_timeline = pipeline_hf.run_timeline
|
| 133 |
+
log.info("Monkeypatched animoflow-api pipeline.run / run_timeline → pipeline_hf")
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# Step 4: import the FastAPI app from animoflow-api/api/main.py
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
from main import app as fastapi_app # noqa: E402
|
| 140 |
+
|
| 141 |
+
log.info("Imported animoflow-api FastAPI app (title=%r)", fastapi_app.title)
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# Step 4b: warm up the multilingual prompt rewriter in the parent process so
|
| 145 |
+
# the @spaces.GPU fork inherits the loaded Qwen + MiniLM + corpus via COW on
|
| 146 |
+
# every subsequent call. Without this, the very first GPU rewrite has to
|
| 147 |
+
# pay the model-load + CUDA-move cost INSIDE the @GPU budget, and gets killed
|
| 148 |
+
# by ZeroGPU with 'GPU task aborted' once it exceeds the duration.
|
| 149 |
+
# Same eager-load pattern as the MDM registry (animoflow_models/registry.py).
|
| 150 |
+
# ---------------------------------------------------------------------------
|
| 151 |
+
try:
|
| 152 |
+
import rewriter as _rewriter # noqa: E402 (animoflow-api's module)
|
| 153 |
+
_rewriter.warmup()
|
| 154 |
+
log.info("Rewriter warmed up in orchestrator process")
|
| 155 |
+
except ImportError:
|
| 156 |
+
log.warning("Rewriter module not on sys.path — skipping warmup")
|
| 157 |
+
except Exception as e:
|
| 158 |
+
log.warning("Rewriter warmup raised — first /v1/jobs request will retry: %s", e)
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# Step 5: build the Gradio Blocks UI and mount on "/"
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
import gradio as gr # noqa: E402
|
| 165 |
+
from ui import build_blocks # noqa: E402
|
| 166 |
+
|
| 167 |
+
_blocks = build_blocks()
|
| 168 |
+
|
| 169 |
+
# Kimodo health surface. animoflow-api's _MODEL_HEALTH_URLS polls
|
| 170 |
+
# ${KIMODO_ENDPOINT}/health every HEALTH_POLL_INTERVAL seconds. Default
|
| 171 |
+
# KIMODO_ENDPOINT in animoflow-api/api/config.py points at localhost:8005
|
| 172 |
+
# (the local-stack Docker container), which is unreachable inside the HF
|
| 173 |
+
# Space. bootstrap._set_env_for_kimodo plants KIMODO_ENDPOINT pointing at
|
| 174 |
+
# this in-process route — Kimodo surfaces as available in /v1/models (and
|
| 175 |
+
# the webUI dropdown) once the venv build finishes.
|
| 176 |
+
@fastapi_app.get("/__internal/kimodo/health")
|
| 177 |
+
def _kimodo_internal_health(): # noqa: D401
|
| 178 |
+
from fastapi.responses import JSONResponse
|
| 179 |
+
# escape_hatch/__init__.py does `from .invoke import invoke` which
|
| 180 |
+
# overwrites the submodule reference with the function. Even
|
| 181 |
+
# `import escape_hatch.invoke as X` then binds X to the function. Pull
|
| 182 |
+
# the module straight from sys.modules to bypass the shadowing.
|
| 183 |
+
import escape_hatch # noqa: F401 — ensure escape_hatch.invoke is in sys.modules
|
| 184 |
+
import sys as _sys
|
| 185 |
+
_ehi = _sys.modules["escape_hatch.invoke"]
|
| 186 |
+
|
| 187 |
+
if os.environ.get("ENABLE_KIMODO", "true").strip().lower() == "false":
|
| 188 |
+
return JSONResponse(status_code=503, content={"status": "disabled"})
|
| 189 |
+
failed = _ehi._kimodo_failed_message()
|
| 190 |
+
if failed:
|
| 191 |
+
return JSONResponse(
|
| 192 |
+
status_code=503,
|
| 193 |
+
content={"status": "failed", "error": failed[:400]},
|
| 194 |
+
)
|
| 195 |
+
event = _ehi._KIMODO_READY
|
| 196 |
+
ready_sentinel = _ehi._external_dir() / ".kimodo_ready"
|
| 197 |
+
ready = ready_sentinel.is_file() or (event is not None and event.is_set())
|
| 198 |
+
if ready:
|
| 199 |
+
return {"status": "ok"}
|
| 200 |
+
return JSONResponse(status_code=503, content={"status": "warming"})
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# `gr.mount_gradio_app` registers Gradio's routes on the FastAPI app.
|
| 204 |
+
# WEB_DIR=/nonexistent in our Dockerfile makes animoflow-api skip its
|
| 205 |
+
# StaticFiles("/") catch-all, so "/" is free for Gradio to claim.
|
| 206 |
+
_output_dir = os.environ.get("OUTPUT_DIR", "/tmp/animoflow-output")
|
| 207 |
+
fastapi_app = gr.mount_gradio_app(
|
| 208 |
+
fastapi_app, _blocks, path="/",
|
| 209 |
+
allowed_paths=[_output_dir],
|
| 210 |
+
)
|
| 211 |
+
log.info("Mounted Gradio UI at / (allowed_paths=%s)", [_output_dir])
|
| 212 |
+
|
| 213 |
+
# HF Gradio SDK auto-detection: expose `demo` at module level
|
| 214 |
+
demo = _blocks
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def main() -> None:
|
| 218 |
+
"""Local-dev convenience entry: `python app.py` boots uvicorn."""
|
| 219 |
+
import uvicorn
|
| 220 |
+
|
| 221 |
+
uvicorn.run(
|
| 222 |
+
"app:fastapi_app",
|
| 223 |
+
host="0.0.0.0",
|
| 224 |
+
port=int(os.environ.get("PORT", "7860")),
|
| 225 |
+
log_level=os.environ.get("LOG_LEVEL", "info").lower(),
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
if __name__ == "__main__":
|
| 230 |
+
if os.environ.get("SPACE_ID"):
|
| 231 |
+
# On HF Gradio SDK — ZeroGPU requires demo.launch() for GPU
|
| 232 |
+
# registration. We monkey-patch Gradio's app factory to inject
|
| 233 |
+
# our /v1/* FastAPI routes into the Gradio-managed app.
|
| 234 |
+
import gradio.routes as _gr_routes
|
| 235 |
+
|
| 236 |
+
_orig_create = _gr_routes.App.create_app
|
| 237 |
+
|
| 238 |
+
def _patched_create(blocks, **kw):
|
| 239 |
+
gapp = _orig_create(blocks, **kw)
|
| 240 |
+
# Inject animoflow-api's API routes. PREPEND, don't append —
|
| 241 |
+
# Starlette matches routes in registration order and Gradio
|
| 242 |
+
# ships its own `/openapi.json`, `/docs`, `/redoc` that would
|
| 243 |
+
# otherwise win and serve Gradio's internal spec instead of
|
| 244 |
+
# animoflow-api's branded one (title: "AnimoFlow API",
|
| 245 |
+
# 10 documented /v1/* routes). Inserting at index 0 in
|
| 246 |
+
# reverse iteration order preserves animoflow-api's own
|
| 247 |
+
# ordering. The /v1/* + /oauth/* routes were already unique
|
| 248 |
+
# to animoflow-api so prepending doesn't change their
|
| 249 |
+
# behavior — only the spec-surface routes flip to ours.
|
| 250 |
+
for route in reversed(list(fastapi_app.routes)):
|
| 251 |
+
gapp.routes.insert(0, route)
|
| 252 |
+
# Copy middleware
|
| 253 |
+
for mw in fastapi_app.user_middleware:
|
| 254 |
+
gapp.add_middleware(mw.cls, **mw.kwargs)
|
| 255 |
+
# Copy exception handlers
|
| 256 |
+
for exc_cls, handler in fastapi_app.exception_handlers.items():
|
| 257 |
+
gapp.add_exception_handler(exc_cls, handler)
|
| 258 |
+
# slowapi's RateLimitExceeded handler reads
|
| 259 |
+
# request.app.state.limiter — and request.app here is THIS
|
| 260 |
+
# Gradio app, not animoflow-api's. Without this line every
|
| 261 |
+
# rate-limit hit 500s with AttributeError instead of a 429
|
| 262 |
+
# (found 2026-07-06 by the quota probe's parallel round).
|
| 263 |
+
gapp.state.limiter = fastapi_app.state.limiter
|
| 264 |
+
log.info("Injected animoflow-api routes into Gradio app (prepended for /openapi.json + /redoc + /docs precedence)")
|
| 265 |
+
return gapp
|
| 266 |
+
|
| 267 |
+
_gr_routes.App.create_app = _patched_create
|
| 268 |
+
demo.launch(allowed_paths=[_output_dir])
|
| 269 |
+
else:
|
| 270 |
+
main()
|
bootstrap.py
ADDED
|
@@ -0,0 +1,1416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bootstrap external repos + checkpoints when running on HF Gradio SDK
|
| 3 |
+
Spaces (no Docker build phase to do it for us).
|
| 4 |
+
|
| 5 |
+
In Docker mode the Dockerfile already cloned the wrapper + upstream repos
|
| 6 |
+
and downloaded the baked checkpoints, so this module is a no-op there.
|
| 7 |
+
On HF Gradio Spaces (or any clean OSS-local checkout where the user
|
| 8 |
+
hasn't already provisioned external repos), we git-clone everything to
|
| 9 |
+
a known base dir and call `huggingface_hub.snapshot_download` for the
|
| 10 |
+
checkpoints.
|
| 11 |
+
|
| 12 |
+
Auth:
|
| 13 |
+
* Private GitHub repos via the `gh_token` env var (Space secret on HF;
|
| 14 |
+
plain env var locally).
|
| 15 |
+
* Private HF Hub repo via `hf_token` env var.
|
| 16 |
+
|
| 17 |
+
Both never persisted to disk: the gh_token is used in a clone URL only
|
| 18 |
+
during the clone, then stripped from the remote URL post-clone.
|
| 19 |
+
|
| 20 |
+
Idempotent — safe to call multiple times. Skips any repo / file that
|
| 21 |
+
already exists at the target path.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
import os
|
| 28 |
+
import shutil
|
| 29 |
+
import subprocess
|
| 30 |
+
import sys
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
log = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Config — overridable via env vars so OSS users can point this anywhere.
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
# Base dir for cloned external repos. On HF Gradio Spaces the working
|
| 40 |
+
# directory is /home/user/app/, so external/ sits next to the app code.
|
| 41 |
+
# In Docker mode the Dockerfile uses /opt/* and this whole module is a no-op
|
| 42 |
+
# (the Docker-mode marker is COMFYUI_ANIMOFLOW_DIR pointing at /opt/...).
|
| 43 |
+
_DEFAULT_EXTERNAL = (
|
| 44 |
+
"/home/user/app/external"
|
| 45 |
+
if os.path.isdir("/home/user/app")
|
| 46 |
+
else str(Path(__file__).resolve().parent / "external")
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
EXTERNAL_DIR = Path(os.environ.get("ANIMOFLOW_EXTERNAL_DIR", _DEFAULT_EXTERNAL))
|
| 50 |
+
|
| 51 |
+
# Pinned upstream SHAs / revisions. Bump deliberately, not via git pull.
|
| 52 |
+
# MDM repo HEAD diverged (BERT encoder, refactored encode_text → shape
|
| 53 |
+
# changes, new imports) and breaks text-conditioned generation. Pin to the
|
| 54 |
+
# last known-good commit that matches the checkpoint's architecture.
|
| 55 |
+
MDM_PINNED_COMMIT = os.environ.get("MDM_PINNED_COMMIT", "af061ca")
|
| 56 |
+
|
| 57 |
+
# Optional pins for AnimoFlow's own repos (empty = track main, today's
|
| 58 |
+
# behavior). Set these as Space variables at release time so a rebuild can
|
| 59 |
+
# never silently pick up newer main — e.g. the v0.1.0-beta tag's SHAs.
|
| 60 |
+
COMFYUI_PINNED_COMMIT = os.environ.get("COMFYUI_PINNED_COMMIT", "")
|
| 61 |
+
API_PINNED_COMMIT = os.environ.get("API_PINNED_COMMIT", "")
|
| 62 |
+
|
| 63 |
+
ANIMOFLOW_CHECKPOINTS_REPO = os.environ.get(
|
| 64 |
+
"ANIMOFLOW_CHECKPOINTS_REPO", "AnimoFlow/animoflow-checkpoints"
|
| 65 |
+
)
|
| 66 |
+
ANIMOFLOW_CHECKPOINTS_REVISION = os.environ.get(
|
| 67 |
+
"ANIMOFLOW_CHECKPOINTS_REVISION",
|
| 68 |
+
# 841707a = Pete removed; f0af610 = The Boss removed (2026-07-03,
|
| 69 |
+
# Guy's call). Predecessor
|
| 70 |
+
# cfbfe3c = first commit with characters/** (2026-07-03 webui character
|
| 71 |
+
# bake — Mixamo FBXs live in this private repo, NOT in public
|
| 72 |
+
# comfyui-animoflow; current roster: Vanguard/Knight/Suzie/Doozy). Predecessor
|
| 73 |
+
# ff21939 was the first commit with priormdm/** (2026-06-21); its
|
| 74 |
+
# predecessor 6df4521 had momask/t2m/** but no priorMDM, so
|
| 75 |
+
# snapshot_download with allow_patterns=["priormdm/**"] globbed nothing
|
| 76 |
+
# and the registry's _load("priormdm") failed cleanly at first call —
|
| 77 |
+
# same bug class would hit characters/** on any pre-cfbfe3c revision.
|
| 78 |
+
# 2026-07-06: characters/** moved OUT to ANIMOFLOW_CHARACTER_ASSETS_REPO
|
| 79 |
+
# (Adobe-content isolation); this repo/revision now feeds weights only.
|
| 80 |
+
"841707a1584c2ffc6085fff44f5b00c555fab634",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
ANIMOFLOW_CHARACTER_ASSETS_REPO = os.environ.get(
|
| 84 |
+
"ANIMOFLOW_CHARACTER_ASSETS_REPO", "AnimoFlow/character-assets"
|
| 85 |
+
)
|
| 86 |
+
ANIMOFLOW_CHARACTER_ASSETS_REVISION = os.environ.get(
|
| 87 |
+
"ANIMOFLOW_CHARACTER_ASSETS_REVISION",
|
| 88 |
+
# 07d4ab0 = adds Y_bot + Kaya (2026-07-07), byte-identical to the FBXs
|
| 89 |
+
# comfyui-animoflow@7a91fa3 untracked for public launch — full 6-rig
|
| 90 |
+
# roster now lives here. Predecessor
|
| 91 |
+
# eec5ed0 = initial commit (2026-07-06): byte-identical characters/**
|
| 92 |
+
# split out of animoflow-checkpoints@841707a — Adobe Mixamo FBXs get
|
| 93 |
+
# their own private repo so the checkpoints repo can be shared without
|
| 94 |
+
# dragging Adobe-licensed content along, and so tokens can be scoped
|
| 95 |
+
# per-repo. eec5ed0 only mirrored the 4 checkpoints-repo rigs
|
| 96 |
+
# (Doozy/Knight/Suzie/Vanguard) — Y_bot/Kaya lived in comfyui HEAD, so
|
| 97 |
+
# the roster silently dropped to 4 on the first deploy of the split.
|
| 98 |
+
# Same silent-glob bug class as documented on
|
| 99 |
+
# ANIMOFLOW_CHECKPOINTS_REVISION applies: allow_patterns=
|
| 100 |
+
# ["characters/**"] globs NOTHING (no error) on a revision lacking the
|
| 101 |
+
# folder — this repo has it from commit one, but keep the pin anyway.
|
| 102 |
+
# NEVER super_squash_history either private repo: pins die with it.
|
| 103 |
+
"07d4ab0955b80af50db35847bf9b01d33087e710",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Marker that says "Docker mode already provisioned everything" — if any of
|
| 107 |
+
# these exist we skip the bootstrap entirely.
|
| 108 |
+
_DOCKER_MODE_PATHS = (
|
| 109 |
+
"/opt/comfyui-animoflow",
|
| 110 |
+
"/opt/animoflow-api",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# What we need to clone. (repo_name, target_subdir, is_private).
|
| 114 |
+
_GIT_REPOS: tuple[tuple[str, str, str, bool], ...] = (
|
| 115 |
+
# (full_url_template_or_url, target_subdir, friendly_name, private)
|
| 116 |
+
(
|
| 117 |
+
"https://github.com/AnimoFlow/comfyui-animoflow.git",
|
| 118 |
+
"comfyui-animoflow",
|
| 119 |
+
"comfyui-animoflow",
|
| 120 |
+
True,
|
| 121 |
+
),
|
| 122 |
+
(
|
| 123 |
+
"https://github.com/AnimoFlow/animoflow-api.git",
|
| 124 |
+
"animoflow-api",
|
| 125 |
+
"animoflow-api",
|
| 126 |
+
True,
|
| 127 |
+
),
|
| 128 |
+
(
|
| 129 |
+
"https://github.com/GuyTevet/motion-diffusion-model.git",
|
| 130 |
+
"mdm-codes",
|
| 131 |
+
"mdm-codes",
|
| 132 |
+
False,
|
| 133 |
+
),
|
| 134 |
+
(
|
| 135 |
+
"https://github.com/EricGuo5513/momask-codes.git",
|
| 136 |
+
"momask-codes",
|
| 137 |
+
"momask-codes",
|
| 138 |
+
False,
|
| 139 |
+
),
|
| 140 |
+
(
|
| 141 |
+
"https://github.com/priorMDM/priorMDM.git",
|
| 142 |
+
"priormdm-codes",
|
| 143 |
+
"priormdm-codes",
|
| 144 |
+
False,
|
| 145 |
+
),
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# Kimodo source repos cloned separately (not in _GIT_REPOS) so that the
|
| 150 |
+
# ENABLE_KIMODO kill-switch can skip them entirely without affecting MDM/MoMask.
|
| 151 |
+
# kimodo-viser is cloned NESTED inside kimodo-src/ to match the editable-install
|
| 152 |
+
# layout the upstream Dockerfile expects (containers/kimodo/Dockerfile:22).
|
| 153 |
+
_KIMODO_REPOS: tuple[tuple[str, str, str], ...] = (
|
| 154 |
+
("https://github.com/nv-tlabs/kimodo.git", "kimodo-src", "kimodo-src"),
|
| 155 |
+
("https://github.com/nv-tlabs/kimodo-viser.git", "kimodo-src/kimodo-viser", "kimodo-viser"),
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _docker_mode() -> bool:
|
| 160 |
+
"""Return True if the Dockerfile already provisioned the external repos."""
|
| 161 |
+
return any(Path(p).is_dir() for p in _DOCKER_MODE_PATHS)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# Blender 5.0.1 portable Linux tarball. Replaces Debian Trixie's apt Blender
|
| 165 |
+
# (4.3.2) — its retarget runtime was ~75x slower per stage timings.
|
| 166 |
+
_BLENDER_VERSION = "5.0.1"
|
| 167 |
+
_BLENDER_TARBALL_URL = (
|
| 168 |
+
f"https://download.blender.org/release/Blender5.0/"
|
| 169 |
+
f"blender-{_BLENDER_VERSION}-linux-x64.tar.xz"
|
| 170 |
+
)
|
| 171 |
+
_BLENDER_INSTALL_DIR = Path("/home/user/app") / f"blender-{_BLENDER_VERSION}-linux-x64"
|
| 172 |
+
_BLENDER_BIN_PATH = _BLENDER_INSTALL_DIR / "blender"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# gltfpack (meshoptimizer) — Draco + Meshopt GLB compression for Plan A.
|
| 176 |
+
# Used by pipeline_hf._compress_glb. Pinning to a release tag (not SHA) because
|
| 177 |
+
# upstream releases are stable artifacts — see [[preview-perf-2026-06-08
|
| 178 |
+
# -handoff]] gotcha 10.
|
| 179 |
+
_GLTFPACK_VERSION = "1.1"
|
| 180 |
+
_GLTFPACK_ZIP_URL = (
|
| 181 |
+
f"https://github.com/zeux/meshoptimizer/releases/download/"
|
| 182 |
+
f"v{_GLTFPACK_VERSION}/gltfpack-ubuntu.zip"
|
| 183 |
+
)
|
| 184 |
+
_GLTFPACK_INSTALL_DIR = Path("/home/user/app/bin")
|
| 185 |
+
_GLTFPACK_BIN_PATH = _GLTFPACK_INSTALL_DIR / "gltfpack"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _install_gltfpack() -> None:
|
| 189 |
+
"""Install gltfpack (from meshoptimizer) for GLB compression.
|
| 190 |
+
|
| 191 |
+
Used by pipeline_hf._compress_glb to apply Draco (geometry) + Meshopt
|
| 192 |
+
(animation tracks + remaining buffers) compression to the GLB output.
|
| 193 |
+
Validated 9.74x compression on a production Y_bot GLB (2.65 MB → 272 KB)
|
| 194 |
+
pre-deployment.
|
| 195 |
+
|
| 196 |
+
Per the no-silent-fallback rule:
|
| 197 |
+
if the download or extract fails, raise. The Space must boot loudly
|
| 198 |
+
on this kind of failure, not boot cleanly and only blow up on the
|
| 199 |
+
first /generate call with a confusing "gltfpack not found" error.
|
| 200 |
+
|
| 201 |
+
Idempotent — if the binary is already present and executable, just
|
| 202 |
+
plant GLTFPACK_BIN and return. No-op in Docker mode (the container
|
| 203 |
+
image is responsible for its own tooling).
|
| 204 |
+
"""
|
| 205 |
+
import time
|
| 206 |
+
import urllib.request
|
| 207 |
+
import zipfile
|
| 208 |
+
|
| 209 |
+
if _docker_mode():
|
| 210 |
+
log.info("_install_gltfpack: docker mode, skip (container handles it)")
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
if _GLTFPACK_BIN_PATH.is_file() and os.access(_GLTFPACK_BIN_PATH, os.X_OK):
|
| 214 |
+
os.environ["GLTFPACK_BIN"] = str(_GLTFPACK_BIN_PATH)
|
| 215 |
+
log.info("_install_gltfpack: already installed at %s", _GLTFPACK_BIN_PATH)
|
| 216 |
+
return
|
| 217 |
+
|
| 218 |
+
log.info("_install_gltfpack: downloading gltfpack %s from %s",
|
| 219 |
+
_GLTFPACK_VERSION, _GLTFPACK_ZIP_URL)
|
| 220 |
+
t0 = time.perf_counter()
|
| 221 |
+
zip_path = Path("/tmp") / f"gltfpack-{_GLTFPACK_VERSION}.zip"
|
| 222 |
+
|
| 223 |
+
# GitHub release downloads work with default Python urllib UA, but
|
| 224 |
+
# mirror the Blender installer's defensive Mozilla UA — cheap insurance
|
| 225 |
+
# if Cloudflare ever changes its mind about default Python clients.
|
| 226 |
+
req = urllib.request.Request(
|
| 227 |
+
_GLTFPACK_ZIP_URL,
|
| 228 |
+
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"},
|
| 229 |
+
)
|
| 230 |
+
with urllib.request.urlopen(req, timeout=120) as src, open(zip_path, "wb") as dst:
|
| 231 |
+
shutil.copyfileobj(src, dst)
|
| 232 |
+
log.info("_install_gltfpack: downloaded in %.1fs (%d KB)",
|
| 233 |
+
time.perf_counter() - t0, zip_path.stat().st_size // 1024)
|
| 234 |
+
|
| 235 |
+
_GLTFPACK_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
| 236 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 237 |
+
zf.extractall(_GLTFPACK_INSTALL_DIR)
|
| 238 |
+
try:
|
| 239 |
+
zip_path.unlink()
|
| 240 |
+
except OSError:
|
| 241 |
+
pass
|
| 242 |
+
|
| 243 |
+
if not _GLTFPACK_BIN_PATH.is_file():
|
| 244 |
+
raise RuntimeError(
|
| 245 |
+
f"_install_gltfpack: expected binary at {_GLTFPACK_BIN_PATH} after "
|
| 246 |
+
f"extracting {_GLTFPACK_ZIP_URL} — upstream asset layout may have "
|
| 247 |
+
f"changed; check `gh release view v{_GLTFPACK_VERSION} -R "
|
| 248 |
+
f"zeux/meshoptimizer --json assets`"
|
| 249 |
+
)
|
| 250 |
+
os.chmod(_GLTFPACK_BIN_PATH, 0o755)
|
| 251 |
+
os.environ["GLTFPACK_BIN"] = str(_GLTFPACK_BIN_PATH)
|
| 252 |
+
log.info("_install_gltfpack: installed at %s (%d KB)",
|
| 253 |
+
_GLTFPACK_BIN_PATH, _GLTFPACK_BIN_PATH.stat().st_size // 1024)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _install_blender_portable() -> None:
|
| 257 |
+
"""Install Blender 5.0.1 portable Linux tarball and point BLENDER_BIN at it.
|
| 258 |
+
|
| 259 |
+
Replaces Debian Trixie's apt Blender 4.3.2 which is ~75x slower at the
|
| 260 |
+
BVH→FBX retarget step per the Space's [STAGE_TIMINGS] (~90s/job vs ~1.2s
|
| 261 |
+
on Mac native Blender 5.0.1).
|
| 262 |
+
|
| 263 |
+
Idempotent: if the binary already exists at the install path, just sets
|
| 264 |
+
BLENDER_BIN and returns. No-op in Docker mode (the container's
|
| 265 |
+
Dockerfile.cpu already pins its own Blender).
|
| 266 |
+
"""
|
| 267 |
+
import subprocess as _subproc
|
| 268 |
+
import tarfile
|
| 269 |
+
import time
|
| 270 |
+
import urllib.request
|
| 271 |
+
|
| 272 |
+
if _docker_mode():
|
| 273 |
+
log.info("_install_blender_portable: docker mode, skip (container handles Blender)")
|
| 274 |
+
return
|
| 275 |
+
|
| 276 |
+
# Idempotency check — survives Space restarts when /home/user/app persists
|
| 277 |
+
if _BLENDER_BIN_PATH.is_file():
|
| 278 |
+
os.environ["BLENDER_BIN"] = str(_BLENDER_BIN_PATH)
|
| 279 |
+
log.info("_install_blender_portable: already installed at %s", _BLENDER_BIN_PATH)
|
| 280 |
+
return
|
| 281 |
+
|
| 282 |
+
log.info("_install_blender_portable: downloading Blender %s (~360 MB) from %s",
|
| 283 |
+
_BLENDER_VERSION, _BLENDER_TARBALL_URL)
|
| 284 |
+
t0 = time.perf_counter()
|
| 285 |
+
tarball_path = Path("/tmp") / f"blender-{_BLENDER_VERSION}-linux-x64.tar.xz"
|
| 286 |
+
# download.blender.org is Cloudflare-fronted and 403s on default Python UA.
|
| 287 |
+
req = urllib.request.Request(
|
| 288 |
+
_BLENDER_TARBALL_URL,
|
| 289 |
+
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"},
|
| 290 |
+
)
|
| 291 |
+
try:
|
| 292 |
+
with urllib.request.urlopen(req, timeout=300) as src, open(tarball_path, "wb") as dst:
|
| 293 |
+
while True:
|
| 294 |
+
chunk = src.read(1024 * 1024)
|
| 295 |
+
if not chunk:
|
| 296 |
+
break
|
| 297 |
+
dst.write(chunk)
|
| 298 |
+
except Exception as e:
|
| 299 |
+
log.warning("_install_blender_portable: download FAILED (%s) — falling back to apt Blender", e)
|
| 300 |
+
return
|
| 301 |
+
log.info("_install_blender_portable: downloaded in %.1fs (%d MB)",
|
| 302 |
+
time.perf_counter() - t0, tarball_path.stat().st_size // (1024 * 1024))
|
| 303 |
+
|
| 304 |
+
t1 = time.perf_counter()
|
| 305 |
+
install_parent = _BLENDER_INSTALL_DIR.parent
|
| 306 |
+
install_parent.mkdir(parents=True, exist_ok=True)
|
| 307 |
+
try:
|
| 308 |
+
with tarfile.open(tarball_path, "r:xz") as tar:
|
| 309 |
+
tar.extractall(install_parent)
|
| 310 |
+
except Exception as e:
|
| 311 |
+
log.warning("_install_blender_portable: extract FAILED (%s) — falling back to apt Blender", e)
|
| 312 |
+
return
|
| 313 |
+
try:
|
| 314 |
+
tarball_path.unlink()
|
| 315 |
+
except Exception:
|
| 316 |
+
pass
|
| 317 |
+
log.info("_install_blender_portable: extracted in %.1fs to %s",
|
| 318 |
+
time.perf_counter() - t1, _BLENDER_INSTALL_DIR)
|
| 319 |
+
|
| 320 |
+
if not _BLENDER_BIN_PATH.is_file():
|
| 321 |
+
log.warning("_install_blender_portable: binary missing at %s after extract — falling back to apt Blender",
|
| 322 |
+
_BLENDER_BIN_PATH)
|
| 323 |
+
return
|
| 324 |
+
|
| 325 |
+
os.environ["BLENDER_BIN"] = str(_BLENDER_BIN_PATH)
|
| 326 |
+
|
| 327 |
+
# Smoke test
|
| 328 |
+
try:
|
| 329 |
+
ver = _subproc.run(
|
| 330 |
+
[str(_BLENDER_BIN_PATH), "--version"],
|
| 331 |
+
capture_output=True, text=True, timeout=30,
|
| 332 |
+
)
|
| 333 |
+
first_line = (ver.stdout or "").splitlines()[0] if ver.stdout else ""
|
| 334 |
+
log.info("_install_blender_portable: BLENDER_BIN=%s | %s",
|
| 335 |
+
_BLENDER_BIN_PATH, first_line or "<no output>")
|
| 336 |
+
except Exception as e:
|
| 337 |
+
log.warning("_install_blender_portable: version smoke failed (%s) but BLENDER_BIN set anyway", e)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
# Kimodo escape-hatch venv builder
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
# Kimodo's NVIDIA stack pins transformers==5.1.0 + py-soma-x + warp-lang etc.
|
| 344 |
+
# which fight the MDM/MoMask pins in the orchestrator venv. We isolate it in
|
| 345 |
+
# /home/user/app/venvs/kimodo/ and invoke via subprocess from escape_hatch.
|
| 346 |
+
# Build runs in a background thread so the Gradio UI comes up promptly and
|
| 347 |
+
# MDM/MoMask stay usable while Kimodo warms (~5-10 min cold install: pip deps
|
| 348 |
+
# + 16 GB LLaMA-3-8B encoder download).
|
| 349 |
+
#
|
| 350 |
+
# Sentinels in EXTERNAL_DIR:
|
| 351 |
+
# .kimodo_ready — venv built, weights cached. Set on success.
|
| 352 |
+
# .kimodo_failed — captured error text. Set on any failure during install.
|
| 353 |
+
#
|
| 354 |
+
# Per the no-silent-fallback rule: any failure raises loudly and writes
|
| 355 |
+
# .kimodo_failed; the runner script + escape_hatch.invoke surface it to the UI.
|
| 356 |
+
|
| 357 |
+
_KIMODO_VENV_DIR = Path("/home/user/app/venvs/kimodo")
|
| 358 |
+
_KIMODO_BUILD_THREAD: "threading.Thread | None" = None
|
| 359 |
+
_KIMODO_BUILD_EVENT: "threading.Event | None" = None # set when ready
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _kimodo_ready_sentinel() -> Path:
|
| 363 |
+
return EXTERNAL_DIR / ".kimodo_ready"
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _kimodo_failed_sentinel() -> Path:
|
| 367 |
+
return EXTERNAL_DIR / ".kimodo_failed"
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def _kimodo_text_encoders_dir() -> Path:
|
| 371 |
+
return EXTERNAL_DIR / "text-encoders"
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def _kimodo_assets_dir() -> Path:
|
| 375 |
+
return EXTERNAL_DIR / "kimodo-assets"
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _kimodo_src_dir() -> Path:
|
| 379 |
+
return EXTERNAL_DIR / "kimodo-src"
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _clone_kimodo_repos() -> None:
|
| 383 |
+
"""Clone Kimodo + kimodo-viser. Idempotent. Skipped via ENABLE_KIMODO=false."""
|
| 384 |
+
EXTERNAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 385 |
+
for url, subdir, name in _KIMODO_REPOS:
|
| 386 |
+
target = EXTERNAL_DIR / subdir
|
| 387 |
+
if target.is_dir():
|
| 388 |
+
log.info("kimodo-clone: %s already at %s — skip", name, target)
|
| 389 |
+
continue
|
| 390 |
+
log.info("kimodo-clone (public): %s → %s", name, target)
|
| 391 |
+
_git_clone(url, target)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _patch_kimodo_requirements() -> None:
|
| 395 |
+
"""Strip MotionCorrection from Kimodo's lockfile (C++ post-processor that
|
| 396 |
+
needs cmake + CUDA; we don't use post-processing). Mirrors the Docker
|
| 397 |
+
Dockerfile RUN sed -i '/MotionCorrection/d' line. Idempotent."""
|
| 398 |
+
req_path = _kimodo_src_dir() / "docker_requirements.txt"
|
| 399 |
+
if not req_path.is_file():
|
| 400 |
+
log.warning("_patch_kimodo_requirements: %s not found, skipping", req_path)
|
| 401 |
+
return
|
| 402 |
+
text = req_path.read_text()
|
| 403 |
+
if "MotionCorrection" not in text:
|
| 404 |
+
log.info("_patch_kimodo_requirements: already stripped — skip")
|
| 405 |
+
return
|
| 406 |
+
new_lines = [ln for ln in text.splitlines(keepends=True) if "MotionCorrection" not in ln]
|
| 407 |
+
req_path.write_text("".join(new_lines))
|
| 408 |
+
log.info("_patch_kimodo_requirements: stripped MotionCorrection from %s", req_path)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _kimodo_venv_python() -> Path:
|
| 412 |
+
return _KIMODO_VENV_DIR / "bin" / "python"
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _ensure_kimodo_venv() -> None:
|
| 416 |
+
"""Create the venv with stdlib `venv`. Idempotent."""
|
| 417 |
+
if _kimodo_venv_python().is_file():
|
| 418 |
+
log.info("_ensure_kimodo_venv: venv already at %s — skip", _KIMODO_VENV_DIR)
|
| 419 |
+
return
|
| 420 |
+
log.info("_ensure_kimodo_venv: creating venv at %s", _KIMODO_VENV_DIR)
|
| 421 |
+
_KIMODO_VENV_DIR.parent.mkdir(parents=True, exist_ok=True)
|
| 422 |
+
subprocess.run(
|
| 423 |
+
[sys.executable, "-m", "venv", str(_KIMODO_VENV_DIR)],
|
| 424 |
+
check=True, capture_output=True, text=True,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def _kimodo_pip(*args: str, env_extra: dict | None = None, cwd: str | None = None) -> None:
|
| 429 |
+
"""Run pip inside the Kimodo venv. Raises on non-zero.
|
| 430 |
+
|
| 431 |
+
``cwd`` is required when the lockfile contains relative editable paths
|
| 432 |
+
like ``-e ./kimodo-viser`` (Kimodo's ``docker_requirements.txt`` does
|
| 433 |
+
this). The upstream Dockerfile changes directory before running pip;
|
| 434 |
+
mirror that.
|
| 435 |
+
"""
|
| 436 |
+
cmd = [str(_kimodo_venv_python()), "-m", "pip", *args]
|
| 437 |
+
log.info(
|
| 438 |
+
"kimodo-pip%s: %s",
|
| 439 |
+
f" (cwd={cwd})" if cwd else "",
|
| 440 |
+
" ".join(args[:3]) + (" …" if len(args) > 3 else ""),
|
| 441 |
+
)
|
| 442 |
+
env = {**os.environ, **(env_extra or {})}
|
| 443 |
+
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=cwd)
|
| 444 |
+
if proc.returncode != 0:
|
| 445 |
+
# Surface tail of stderr AND stdout (pip writes most of its useful
|
| 446 |
+
# diagnostics to stdout, not stderr) so the .kimodo_failed sentinel
|
| 447 |
+
# captures the actionable detail. 4000 chars each is generous but
|
| 448 |
+
# the sentinel is one-shot per build attempt.
|
| 449 |
+
raise RuntimeError(
|
| 450 |
+
f"kimodo-pip failed (rc={proc.returncode}): {' '.join(args[:8])}\n"
|
| 451 |
+
f"stdout (last 4000 chars): {proc.stdout[-4000:]}\n"
|
| 452 |
+
f"stderr (last 4000 chars): {proc.stderr[-4000:]}"
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def _install_kimodo_deps() -> None:
|
| 457 |
+
"""Install Kimodo's pinned lockfile into the Kimodo venv."""
|
| 458 |
+
req = _kimodo_src_dir() / "docker_requirements.txt"
|
| 459 |
+
if not req.is_file():
|
| 460 |
+
raise RuntimeError(f"Kimodo docker_requirements.txt missing at {req}")
|
| 461 |
+
# Upgrade pip + install wheel/setuptools first. The Kimodo Docker image
|
| 462 |
+
# is based on nvcr.io/nvidia/pytorch which ships these; our stdlib venv
|
| 463 |
+
# does not, and some pinned deps build sdists that need `wheel` present.
|
| 464 |
+
_kimodo_pip("install", "--upgrade", "pip", "setuptools", "wheel")
|
| 465 |
+
# docker_requirements.txt contains `-e ./kimodo-viser` (relative editable
|
| 466 |
+
# install). pip resolves it against the current working directory, so we
|
| 467 |
+
# must `cd` into kimodo-src — matching containers/kimodo/Dockerfile L30
|
| 468 |
+
# (`RUN cd /workspace/kimodo-src && pip install -r docker_requirements.txt`).
|
| 469 |
+
# The lockfile also triggers py-soma-x's setup.py which conditionally
|
| 470 |
+
# imports MotionCorrection if SKIP_MOTION_CORRECTION_IN_SETUP is unset.
|
| 471 |
+
_kimodo_pip(
|
| 472 |
+
"install", "-r", "docker_requirements.txt",
|
| 473 |
+
env_extra={"SKIP_MOTION_CORRECTION_IN_SETUP": "1"},
|
| 474 |
+
cwd=str(_kimodo_src_dir()),
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _prefetch_llama_encoder(hf_token: str | None) -> None:
|
| 479 |
+
"""Pre-fetch LLaMA-3-8B (NousResearch ungated mirror) into the
|
| 480 |
+
text-encoders dir so first inference doesn't pay the ~16 GB download."""
|
| 481 |
+
# Use the ORCHESTRATOR's huggingface_hub (cheaper than spawning the Kimodo
|
| 482 |
+
# venv just to download). Both venvs see the same EXTERNAL_DIR mount.
|
| 483 |
+
from huggingface_hub import snapshot_download
|
| 484 |
+
|
| 485 |
+
target = _kimodo_text_encoders_dir() / "NousResearch" / "Meta-Llama-3-8B-Instruct"
|
| 486 |
+
if (target / "config.json").is_file():
|
| 487 |
+
log.info("_prefetch_llama_encoder: already at %s — skip", target)
|
| 488 |
+
return
|
| 489 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 490 |
+
log.info("_prefetch_llama_encoder: downloading ~16 GB to %s", target)
|
| 491 |
+
snapshot_download(
|
| 492 |
+
repo_id="NousResearch/Meta-Llama-3-8B-Instruct",
|
| 493 |
+
local_dir=str(target),
|
| 494 |
+
token=hf_token, # works with or without token (this repo is ungated)
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _patch_llm2vec_configs() -> None:
|
| 500 |
+
"""Rewrite LLM2Vec adapter configs to point at the ungated LLaMA mirror.
|
| 501 |
+
|
| 502 |
+
Reuses comfyui-animoflow/containers/kimodo/app.py:_patch_llm2vec_for_ungated_llama
|
| 503 |
+
by importing it as a plain function (no FastAPI app served)."""
|
| 504 |
+
container_kimodo = (
|
| 505 |
+
Path(os.environ.get("COMFYUI_ANIMOFLOW_DIR", str(EXTERNAL_DIR / "comfyui-animoflow")))
|
| 506 |
+
/ "containers" / "kimodo"
|
| 507 |
+
)
|
| 508 |
+
app_py = container_kimodo / "app.py"
|
| 509 |
+
if not app_py.is_file():
|
| 510 |
+
raise RuntimeError(
|
| 511 |
+
f"_patch_llm2vec_configs: helper not found at {app_py}. "
|
| 512 |
+
"comfyui-animoflow must be cloned before this step."
|
| 513 |
+
)
|
| 514 |
+
# Plant TEXT_ENCODERS_DIR + HF_HOME so the helper writes to our paths.
|
| 515 |
+
os.environ["TEXT_ENCODERS_DIR"] = str(_kimodo_text_encoders_dir())
|
| 516 |
+
# Import-via-spec so the module's top-level FastAPI construction doesn't
|
| 517 |
+
# require Kimodo deps in the orchestrator venv (the helper itself only
|
| 518 |
+
# needs huggingface_hub which we have).
|
| 519 |
+
import importlib.util as _ilu
|
| 520 |
+
spec = _ilu.spec_from_file_location("_kimodo_helpers", app_py)
|
| 521 |
+
mod = _ilu.module_from_spec(spec) # type: ignore[arg-type]
|
| 522 |
+
# The helper imports torch + fastapi at module top. To dodge those when
|
| 523 |
+
# we only want the config rewrite, monkey-load the source and exec only
|
| 524 |
+
# the function we need.
|
| 525 |
+
source = app_py.read_text()
|
| 526 |
+
# Extract just the _patch_llm2vec_for_ungated_llama function by string
|
| 527 |
+
# slicing — small, stable surface. Falls back to full module exec if the
|
| 528 |
+
# markers move (with a clearer error than NameError later).
|
| 529 |
+
start = source.find("def _patch_llm2vec_for_ungated_llama")
|
| 530 |
+
if start < 0:
|
| 531 |
+
raise RuntimeError(
|
| 532 |
+
"_patch_llm2vec_configs: marker 'def _patch_llm2vec_for_ungated_llama' "
|
| 533 |
+
f"not found in {app_py}. Source layout changed; update bootstrap."
|
| 534 |
+
)
|
| 535 |
+
# Find the end of the function — next top-level def or end of file.
|
| 536 |
+
end_markers = ("\ndef ", "\nclass ", "\n# -")
|
| 537 |
+
end = len(source)
|
| 538 |
+
for m in end_markers:
|
| 539 |
+
idx = source.find(m, start + 1)
|
| 540 |
+
if idx > 0:
|
| 541 |
+
end = min(end, idx)
|
| 542 |
+
fn_source = source[start:end]
|
| 543 |
+
# The function also references module-level constants we need to plant.
|
| 544 |
+
preamble = (
|
| 545 |
+
"import json, os\n"
|
| 546 |
+
"from pathlib import Path\n"
|
| 547 |
+
"from huggingface_hub import snapshot_download\n"
|
| 548 |
+
f"_ENCODERS_DIR = {str(_kimodo_text_encoders_dir())!r}\n"
|
| 549 |
+
"_UNGATED_LLAMA = 'NousResearch/Meta-Llama-3-8B-Instruct'\n"
|
| 550 |
+
"_LLM2VEC_ADAPTERS = [\n"
|
| 551 |
+
" 'McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp',\n"
|
| 552 |
+
" 'McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp-supervised',\n"
|
| 553 |
+
"]\n"
|
| 554 |
+
)
|
| 555 |
+
ns: dict = {}
|
| 556 |
+
exec(preamble + fn_source, ns) # noqa: S102 — controlled source from our own repo
|
| 557 |
+
ns["_patch_llm2vec_for_ungated_llama"]()
|
| 558 |
+
log.info("_patch_llm2vec_configs: LLM2Vec adapter configs rewired to ungated LLaMA")
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def _audit_disk() -> None:
|
| 562 |
+
"""Loud warning if /home/user/app is approaching the HF Space quota."""
|
| 563 |
+
try:
|
| 564 |
+
proc = subprocess.run(
|
| 565 |
+
["du", "-sh", "/home/user/app"],
|
| 566 |
+
capture_output=True, text=True, timeout=30,
|
| 567 |
+
)
|
| 568 |
+
log.info("kimodo-bootstrap: disk usage %s", proc.stdout.strip())
|
| 569 |
+
except Exception as e: # noqa: BLE001
|
| 570 |
+
log.warning("kimodo-bootstrap: disk audit failed: %s", e)
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def _install_kimodo_sync(hf_token: str | None) -> None:
|
| 574 |
+
"""Synchronous Kimodo venv build. Called from a background thread by
|
| 575 |
+
_install_kimodo_async. Sets .kimodo_ready on success or .kimodo_failed on
|
| 576 |
+
any error, both consumed by run_inference_kimodo.py + escape_hatch.invoke.
|
| 577 |
+
"""
|
| 578 |
+
import time
|
| 579 |
+
t0 = time.perf_counter()
|
| 580 |
+
failed_sentinel = _kimodo_failed_sentinel()
|
| 581 |
+
ready_sentinel = _kimodo_ready_sentinel()
|
| 582 |
+
# Clear stale sentinels from previous attempts.
|
| 583 |
+
for s in (failed_sentinel, ready_sentinel):
|
| 584 |
+
try:
|
| 585 |
+
s.unlink()
|
| 586 |
+
except FileNotFoundError:
|
| 587 |
+
pass
|
| 588 |
+
try:
|
| 589 |
+
log.info("[kimodo-bootstrap] starting")
|
| 590 |
+
_clone_kimodo_repos()
|
| 591 |
+
_patch_kimodo_requirements()
|
| 592 |
+
_ensure_kimodo_venv()
|
| 593 |
+
_install_kimodo_deps()
|
| 594 |
+
_prefetch_llama_encoder(hf_token)
|
| 595 |
+
_patch_llm2vec_configs()
|
| 596 |
+
_audit_disk()
|
| 597 |
+
ready_sentinel.write_text("ok\n")
|
| 598 |
+
if _KIMODO_BUILD_EVENT is not None:
|
| 599 |
+
_KIMODO_BUILD_EVENT.set()
|
| 600 |
+
log.info(
|
| 601 |
+
"[kimodo-bootstrap] ready in %.0fs (venv=%s, ready=%s)",
|
| 602 |
+
time.perf_counter() - t0, _KIMODO_VENV_DIR, ready_sentinel,
|
| 603 |
+
)
|
| 604 |
+
except Exception as exc: # noqa: BLE001 — capture everything so UI can show it
|
| 605 |
+
import traceback
|
| 606 |
+
msg = f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}"
|
| 607 |
+
try:
|
| 608 |
+
failed_sentinel.write_text(msg)
|
| 609 |
+
except Exception:
|
| 610 |
+
pass
|
| 611 |
+
log.exception("[kimodo-bootstrap] FAILED after %.0fs", time.perf_counter() - t0)
|
| 612 |
+
# Don't re-raise: background thread exits, but the sentinel + UI surface
|
| 613 |
+
# the error. escape_hatch.invoke reads .kimodo_failed and refuses to
|
| 614 |
+
# call into a broken venv.
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def _install_kimodo_async(hf_token: str | None) -> None:
|
| 618 |
+
"""Kick off Kimodo build in a daemon thread. Returns immediately. Calling
|
| 619 |
+
again is a no-op if the build is already running, ready, or failed."""
|
| 620 |
+
import threading as _threading
|
| 621 |
+
global _KIMODO_BUILD_THREAD, _KIMODO_BUILD_EVENT
|
| 622 |
+
|
| 623 |
+
if _KIMODO_BUILD_EVENT is None:
|
| 624 |
+
_KIMODO_BUILD_EVENT = _threading.Event()
|
| 625 |
+
# Expose the event on escape_hatch.invoke for the readiness gate.
|
| 626 |
+
# NOTE: escape_hatch/__init__.py does `from .invoke import invoke` which
|
| 627 |
+
# overwrites the submodule reference with the function — even
|
| 628 |
+
# `import escape_hatch.invoke as X` then binds X to the function. Pull
|
| 629 |
+
# the module from sys.modules to bypass the shadowing.
|
| 630 |
+
try:
|
| 631 |
+
import escape_hatch # noqa: F401 — ensures escape_hatch.invoke is loaded
|
| 632 |
+
import sys as _sys
|
| 633 |
+
_ehi = _sys.modules["escape_hatch.invoke"]
|
| 634 |
+
_ehi._KIMODO_READY = _KIMODO_BUILD_EVENT # type: ignore[attr-defined]
|
| 635 |
+
except Exception: # noqa: BLE001
|
| 636 |
+
# escape_hatch import may fail during early bootstrap; the runner
|
| 637 |
+
# also reads the sentinel file as a backup.
|
| 638 |
+
pass
|
| 639 |
+
|
| 640 |
+
# Fast paths: nothing to do.
|
| 641 |
+
if _kimodo_ready_sentinel().is_file():
|
| 642 |
+
if not _KIMODO_BUILD_EVENT.is_set():
|
| 643 |
+
_KIMODO_BUILD_EVENT.set()
|
| 644 |
+
log.info("_install_kimodo_async: .kimodo_ready already present — skip")
|
| 645 |
+
return
|
| 646 |
+
if _KIMODO_BUILD_THREAD is not None and _KIMODO_BUILD_THREAD.is_alive():
|
| 647 |
+
log.info("_install_kimodo_async: build already in progress — skip")
|
| 648 |
+
return
|
| 649 |
+
if _kimodo_failed_sentinel().is_file():
|
| 650 |
+
log.warning(
|
| 651 |
+
"_install_kimodo_async: previous build FAILED (see %s). "
|
| 652 |
+
"Delete the sentinel + restart to retry.",
|
| 653 |
+
_kimodo_failed_sentinel(),
|
| 654 |
+
)
|
| 655 |
+
return
|
| 656 |
+
|
| 657 |
+
log.info("_install_kimodo_async: spawning build thread")
|
| 658 |
+
_KIMODO_BUILD_THREAD = _threading.Thread(
|
| 659 |
+
target=_install_kimodo_sync,
|
| 660 |
+
args=(hf_token,),
|
| 661 |
+
daemon=True,
|
| 662 |
+
name="kimodo-bootstrap",
|
| 663 |
+
)
|
| 664 |
+
_KIMODO_BUILD_THREAD.start()
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def _set_env_for_kimodo() -> None:
|
| 668 |
+
"""Plant env vars the runner script + escape_hatch + animoflow-api read
|
| 669 |
+
to find the Kimodo venv, runner, assets, and health endpoint."""
|
| 670 |
+
os.environ.setdefault("KIMODO_VENV_PYTHON", str(_kimodo_venv_python()))
|
| 671 |
+
os.environ.setdefault(
|
| 672 |
+
"KIMODO_RUNNER_SCRIPT",
|
| 673 |
+
str(Path(__file__).resolve().parent / "scripts" / "run_inference_kimodo.py"),
|
| 674 |
+
)
|
| 675 |
+
os.environ.setdefault("KIMODO_SRC_DIR", str(_kimodo_src_dir()))
|
| 676 |
+
os.environ.setdefault("KIMODO_ASSETS_DIR", str(_kimodo_assets_dir()))
|
| 677 |
+
os.environ.setdefault("TEXT_ENCODERS_DIR", str(_kimodo_text_encoders_dir()))
|
| 678 |
+
# Health endpoint: animoflow-api's _MODEL_HEALTH_URLS polls
|
| 679 |
+
# ${KIMODO_ENDPOINT}/health. We expose /__internal/kimodo_health on the
|
| 680 |
+
# same FastAPI app — see app.py.
|
| 681 |
+
port = os.environ.get("PORT", "7860")
|
| 682 |
+
os.environ.setdefault(
|
| 683 |
+
"KIMODO_ENDPOINT", f"http://127.0.0.1:{port}/__internal/kimodo",
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def _git_clone(url: str, target: Path, *, depth: int = 1) -> None:
|
| 688 |
+
"""Shell out to `git clone --depth N`. Raises CalledProcessError on failure."""
|
| 689 |
+
subprocess.run(
|
| 690 |
+
["git", "clone", f"--depth={depth}", url, str(target)],
|
| 691 |
+
check=True,
|
| 692 |
+
capture_output=True,
|
| 693 |
+
text=True,
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def _strip_remote_auth(target: Path, clean_url: str) -> None:
|
| 698 |
+
"""Replace the origin URL with the unauthenticated URL post-clone, so the
|
| 699 |
+
token isn't persisted on disk in `.git/config`."""
|
| 700 |
+
subprocess.run(
|
| 701 |
+
["git", "-C", str(target), "remote", "set-url", "origin", clean_url],
|
| 702 |
+
check=True,
|
| 703 |
+
capture_output=True,
|
| 704 |
+
text=True,
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def _clone_all(gh_token: str | None) -> None:
|
| 709 |
+
EXTERNAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 710 |
+
|
| 711 |
+
for url, subdir, name, private in _GIT_REPOS:
|
| 712 |
+
target = EXTERNAL_DIR / subdir
|
| 713 |
+
if target.is_dir():
|
| 714 |
+
log.info("clone: %s already at %s — skip", name, target)
|
| 715 |
+
continue
|
| 716 |
+
if private:
|
| 717 |
+
if not gh_token:
|
| 718 |
+
raise RuntimeError(
|
| 719 |
+
f"gh_token env var missing — cannot clone private repo {name}. "
|
| 720 |
+
"On HF Spaces, configure a Space secret named `gh_token` with "
|
| 721 |
+
"Read access to AnimoFlow private repos."
|
| 722 |
+
)
|
| 723 |
+
auth_url = url.replace("https://", f"https://x-access-token:{gh_token}@")
|
| 724 |
+
log.info("clone (private): %s → %s", name, target)
|
| 725 |
+
_git_clone(auth_url, target)
|
| 726 |
+
_strip_remote_auth(target, url)
|
| 727 |
+
else:
|
| 728 |
+
log.info("clone (public): %s → %s", name, target)
|
| 729 |
+
_git_clone(url, target)
|
| 730 |
+
|
| 731 |
+
# Pin MDM to a known-good commit (HEAD diverged, see MDM_PINNED_COMMIT).
|
| 732 |
+
if subdir == "mdm-codes" and MDM_PINNED_COMMIT:
|
| 733 |
+
_pin_to_commit(target, MDM_PINNED_COMMIT, name)
|
| 734 |
+
# Release pins for AnimoFlow's own repos (no-op while unset).
|
| 735 |
+
if subdir == "comfyui-animoflow" and COMFYUI_PINNED_COMMIT:
|
| 736 |
+
_pin_to_commit(target, COMFYUI_PINNED_COMMIT, name)
|
| 737 |
+
if subdir == "animoflow-api" and API_PINNED_COMMIT:
|
| 738 |
+
_pin_to_commit(target, API_PINNED_COMMIT, name)
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
def _pin_to_commit(target: Path, commit: str, name: str) -> None:
|
| 742 |
+
"""Fetch a specific commit and checkout, so the clone stays at a
|
| 743 |
+
known-good revision regardless of what HEAD points to.
|
| 744 |
+
|
| 745 |
+
GitHub doesn't allow fetching arbitrary SHAs by default on shallow
|
| 746 |
+
clones. Strategy: try shallow fetch first (works on some hosts);
|
| 747 |
+
if that fails, unshallow the repo and then checkout.
|
| 748 |
+
"""
|
| 749 |
+
try:
|
| 750 |
+
# Fast path: try fetching the SHA directly (works on GH if
|
| 751 |
+
# uploadpack.allowReachableSHA1InWant is enabled).
|
| 752 |
+
subprocess.run(
|
| 753 |
+
["git", "-C", str(target), "fetch", "--depth=1", "origin", commit],
|
| 754 |
+
check=True, capture_output=True, text=True,
|
| 755 |
+
)
|
| 756 |
+
except subprocess.CalledProcessError:
|
| 757 |
+
# Slow path: unshallow so the commit is reachable, then checkout.
|
| 758 |
+
log.info("pin: shallow fetch of %s failed for %s — unshallowing", commit, name)
|
| 759 |
+
try:
|
| 760 |
+
subprocess.run(
|
| 761 |
+
["git", "-C", str(target), "fetch", "--unshallow"],
|
| 762 |
+
check=True, capture_output=True, text=True,
|
| 763 |
+
)
|
| 764 |
+
except subprocess.CalledProcessError:
|
| 765 |
+
# Already unshallow or fetch failed — try a plain fetch
|
| 766 |
+
subprocess.run(
|
| 767 |
+
["git", "-C", str(target), "fetch", "origin"],
|
| 768 |
+
check=True, capture_output=True, text=True,
|
| 769 |
+
)
|
| 770 |
+
try:
|
| 771 |
+
subprocess.run(
|
| 772 |
+
["git", "-C", str(target), "checkout", commit],
|
| 773 |
+
check=True, capture_output=True, text=True,
|
| 774 |
+
)
|
| 775 |
+
log.info("pin: %s checked out at %s", name, commit)
|
| 776 |
+
except subprocess.CalledProcessError as exc:
|
| 777 |
+
log.warning("pin: failed to checkout %s at %s: %s", name, commit, exc.stderr)
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def _download_checkpoints(hf_token: str | None) -> Path | None:
|
| 781 |
+
"""Download MDM checkpoints from HF Hub and return the local dir, or
|
| 782 |
+
None if no token (caller decides whether that's fatal)."""
|
| 783 |
+
ckpt_dir = EXTERNAL_DIR / "checkpoints"
|
| 784 |
+
ckpt_dir.mkdir(parents=True, exist_ok=True)
|
| 785 |
+
|
| 786 |
+
target_pt = ckpt_dir / "humanml_enc_512_50steps" / "model000750000.pt"
|
| 787 |
+
if target_pt.exists():
|
| 788 |
+
log.info("checkpoints: already present at %s", ckpt_dir)
|
| 789 |
+
return ckpt_dir
|
| 790 |
+
|
| 791 |
+
if not hf_token:
|
| 792 |
+
log.warning(
|
| 793 |
+
"hf_token env var missing — MDM checkpoints not downloaded. "
|
| 794 |
+
"MDM will fall back to placeholder mode. Configure `hf_token` "
|
| 795 |
+
"Space secret with Read access to %s.",
|
| 796 |
+
ANIMOFLOW_CHECKPOINTS_REPO,
|
| 797 |
+
)
|
| 798 |
+
return None
|
| 799 |
+
|
| 800 |
+
from huggingface_hub import snapshot_download
|
| 801 |
+
|
| 802 |
+
log.info(
|
| 803 |
+
"checkpoints: snapshot_download %s @ %s → %s",
|
| 804 |
+
ANIMOFLOW_CHECKPOINTS_REPO,
|
| 805 |
+
ANIMOFLOW_CHECKPOINTS_REVISION[:8],
|
| 806 |
+
ckpt_dir,
|
| 807 |
+
)
|
| 808 |
+
snapshot_download(
|
| 809 |
+
repo_id=ANIMOFLOW_CHECKPOINTS_REPO,
|
| 810 |
+
revision=ANIMOFLOW_CHECKPOINTS_REVISION,
|
| 811 |
+
repo_type="model",
|
| 812 |
+
local_dir=str(ckpt_dir),
|
| 813 |
+
allow_patterns=[
|
| 814 |
+
"humanml_enc_512_50steps/model000750000.pt",
|
| 815 |
+
"humanml_enc_512_50steps/args.json",
|
| 816 |
+
# MoMask T2M checkpoints — required by inference_momask.py which
|
| 817 |
+
# reads `${CHECKPOINTS_DIR}/t2m/{rvq,t2m,tres,length_estimator}/...`.
|
| 818 |
+
# We override CHECKPOINTS_DIR to `${ckpt_dir}/momask` per-model in
|
| 819 |
+
# registry._load() so this path becomes `${ckpt_dir}/momask/t2m/...`.
|
| 820 |
+
"momask/t2m/**",
|
| 821 |
+
# priorMDM trajectory + timeline heads. The registry's
|
| 822 |
+
# env_overrides_factory plants CHECKPOINT_DIR=${ckpt_dir}/priormdm
|
| 823 |
+
# so inference.py's _discover_checkpoint() looks inside
|
| 824 |
+
# priormdm/{root_horizontal_control_50steps,humanml-encoder-512-50steps}/.
|
| 825 |
+
"priormdm/**",
|
| 826 |
+
],
|
| 827 |
+
token=hf_token,
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
# Copy t2m_mean / t2m_std from the comfyui-animoflow clone — the
|
| 831 |
+
# MDM wrapper expects them in WEIGHTS_DIR alongside the .pt.
|
| 832 |
+
src_dir = EXTERNAL_DIR / "comfyui-animoflow" / "containers" / "mdm"
|
| 833 |
+
for npy in ("t2m_mean.npy", "t2m_std.npy"):
|
| 834 |
+
src = src_dir / npy
|
| 835 |
+
dst = ckpt_dir / npy
|
| 836 |
+
if src.exists() and not dst.exists():
|
| 837 |
+
shutil.copyfile(src, dst)
|
| 838 |
+
log.info("copied %s → %s", src, dst)
|
| 839 |
+
|
| 840 |
+
# Mirror for priorMDM: its inference.py reads mean/std from WEIGHTS_DIR
|
| 841 |
+
# which the registry sets to ${ckpt_dir}/priormdm. Same files shipped in
|
| 842 |
+
# the priormdm container — copy them so the path resolves.
|
| 843 |
+
pmdm_src_dir = EXTERNAL_DIR / "comfyui-animoflow" / "containers" / "priormdm"
|
| 844 |
+
pmdm_dst_dir = ckpt_dir / "priormdm"
|
| 845 |
+
if pmdm_src_dir.exists():
|
| 846 |
+
pmdm_dst_dir.mkdir(parents=True, exist_ok=True)
|
| 847 |
+
for npy in ("t2m_mean.npy", "t2m_std.npy"):
|
| 848 |
+
src = pmdm_src_dir / npy
|
| 849 |
+
dst = pmdm_dst_dir / npy
|
| 850 |
+
if src.exists() and not dst.exists():
|
| 851 |
+
shutil.copyfile(src, dst)
|
| 852 |
+
log.info("copied %s → %s", src, dst)
|
| 853 |
+
|
| 854 |
+
return ckpt_dir
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def _download_private_characters(hf_token: str | None) -> None:
|
| 858 |
+
"""Fetch the Mixamo character FBXs from the private character-assets
|
| 859 |
+
repo into the cloned comfyui-animoflow characters dir.
|
| 860 |
+
|
| 861 |
+
The public comfyui-animoflow repo only carries download instructions
|
| 862 |
+
(Mixamo terms forbid redistribution) plus the small bone_map.json
|
| 863 |
+
sidecars; the hosted demo gets the actual FBXs from
|
| 864 |
+
AnimoFlow/character-assets `characters/**` (split out of the
|
| 865 |
+
checkpoints repo 2026-07-06 to isolate Adobe-licensed content).
|
| 866 |
+
Idempotent: skips files already present. No token → loud warning,
|
| 867 |
+
characters absent → /v1/characters only lists whatever the public
|
| 868 |
+
clone carries (nothing, since comfyui-animoflow@7a91fa3 untracked its
|
| 869 |
+
dev-convenience FBXs). NOTE: a token that lacks read scope on
|
| 870 |
+
the character-assets repo raises RepositoryNotFoundError here (HF
|
| 871 |
+
404s private repos it can't see) and crashes startup — loud on
|
| 872 |
+
purpose; extend the Space `hf_token` scope, don't wrap this in
|
| 873 |
+
try/except.
|
| 874 |
+
"""
|
| 875 |
+
chars_dir = EXTERNAL_DIR / "comfyui-animoflow" / "characters"
|
| 876 |
+
if not chars_dir.is_dir():
|
| 877 |
+
log.warning("characters: %s missing — clone step failed?", chars_dir)
|
| 878 |
+
return
|
| 879 |
+
if not hf_token:
|
| 880 |
+
log.warning(
|
| 881 |
+
"hf_token env var missing — private characters not downloaded; "
|
| 882 |
+
"the character dropdown will only show the public-clone rigs."
|
| 883 |
+
)
|
| 884 |
+
return
|
| 885 |
+
|
| 886 |
+
from huggingface_hub import snapshot_download
|
| 887 |
+
|
| 888 |
+
log.info(
|
| 889 |
+
"characters: snapshot_download %s @ %s characters/** → %s",
|
| 890 |
+
ANIMOFLOW_CHARACTER_ASSETS_REPO,
|
| 891 |
+
ANIMOFLOW_CHARACTER_ASSETS_REVISION[:8],
|
| 892 |
+
chars_dir,
|
| 893 |
+
)
|
| 894 |
+
import tempfile
|
| 895 |
+
|
| 896 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 897 |
+
snapshot_download(
|
| 898 |
+
repo_id=ANIMOFLOW_CHARACTER_ASSETS_REPO,
|
| 899 |
+
revision=ANIMOFLOW_CHARACTER_ASSETS_REVISION,
|
| 900 |
+
repo_type="model",
|
| 901 |
+
token=hf_token,
|
| 902 |
+
local_dir=tmp,
|
| 903 |
+
allow_patterns=["characters/**"],
|
| 904 |
+
)
|
| 905 |
+
src_dir = Path(tmp) / "characters"
|
| 906 |
+
if not src_dir.is_dir():
|
| 907 |
+
# The silent-glob failure mode: download "succeeded" but the
|
| 908 |
+
# pattern matched nothing at this revision. Loud, per the
|
| 909 |
+
# no-silent-fallback rule.
|
| 910 |
+
log.warning(
|
| 911 |
+
"characters: %s @ %s has NO characters/ folder — pattern "
|
| 912 |
+
"globbed nothing; the character dropdown will be missing "
|
| 913 |
+
"the private rigs. Check the repo/revision pin.",
|
| 914 |
+
ANIMOFLOW_CHARACTER_ASSETS_REPO,
|
| 915 |
+
ANIMOFLOW_CHARACTER_ASSETS_REVISION[:8],
|
| 916 |
+
)
|
| 917 |
+
return
|
| 918 |
+
n = 0
|
| 919 |
+
for f in sorted(src_dir.iterdir()):
|
| 920 |
+
if not f.is_file():
|
| 921 |
+
continue
|
| 922 |
+
dst = chars_dir / f.name
|
| 923 |
+
if dst.exists() and dst.stat().st_size == f.stat().st_size:
|
| 924 |
+
continue
|
| 925 |
+
shutil.copy2(f, dst)
|
| 926 |
+
n += 1
|
| 927 |
+
log.info("characters: installed %d file(s) into %s", n, chars_dir)
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
def _set_env_for_external() -> None:
|
| 931 |
+
"""Plant env vars that the rest of the orchestrator code reads, so
|
| 932 |
+
pipeline_hf, models.registry, etc. find the cloned paths without
|
| 933 |
+
knowing whether we're in Docker or Gradio mode."""
|
| 934 |
+
os.environ.setdefault(
|
| 935 |
+
"COMFYUI_ANIMOFLOW_DIR", str(EXTERNAL_DIR / "comfyui-animoflow")
|
| 936 |
+
)
|
| 937 |
+
os.environ.setdefault(
|
| 938 |
+
"COMFYUI_ANIMOFLOW_NODES_DIR",
|
| 939 |
+
str(EXTERNAL_DIR / "comfyui-animoflow" / "nodes"),
|
| 940 |
+
)
|
| 941 |
+
os.environ.setdefault(
|
| 942 |
+
"ANIMOFLOW_API_API_DIR", str(EXTERNAL_DIR / "animoflow-api" / "api")
|
| 943 |
+
)
|
| 944 |
+
os.environ.setdefault("MDM_PATH", str(EXTERNAL_DIR / "mdm-codes"))
|
| 945 |
+
os.environ.setdefault("MOMASK_PATH", str(EXTERNAL_DIR / "momask-codes"))
|
| 946 |
+
os.environ.setdefault("PRIORMDM_PATH", str(EXTERNAL_DIR / "priormdm-codes"))
|
| 947 |
+
os.environ.setdefault(
|
| 948 |
+
"CHARACTERS_DIR",
|
| 949 |
+
str(EXTERNAL_DIR / "comfyui-animoflow" / "characters"),
|
| 950 |
+
)
|
| 951 |
+
os.environ.setdefault("WEB_DIR", "/nonexistent")
|
| 952 |
+
os.environ.setdefault("WEIGHTS_DIR", str(EXTERNAL_DIR / "checkpoints"))
|
| 953 |
+
os.environ.setdefault("CHECKPOINTS_DIR", str(EXTERNAL_DIR / "checkpoints"))
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
def _patch_mdm_model_util():
|
| 957 |
+
"""Ensure MDM's utils/model_util.py has a ``load_saved_model`` function.
|
| 958 |
+
|
| 959 |
+
The MDM inference wrapper (comfyui-animoflow/containers/mdm/inference.py)
|
| 960 |
+
imports ``load_saved_model`` from ``utils.model_util``, but the upstream
|
| 961 |
+
MDM repo only exposes ``load_model_wo_clip``. Without this shim the
|
| 962 |
+
import fails silently (caught by a broad except), and the model falls
|
| 963 |
+
back to a hardcoded walk-cycle placeholder — ignoring the text prompt
|
| 964 |
+
entirely.
|
| 965 |
+
|
| 966 |
+
This patch adds a thin ``load_saved_model(model, path, **kw)`` wrapper
|
| 967 |
+
that loads the state dict from *path* and delegates to
|
| 968 |
+
``load_model_wo_clip``. Idempotent — skips if the function already
|
| 969 |
+
exists in the file.
|
| 970 |
+
"""
|
| 971 |
+
mdm_path = EXTERNAL_DIR / "mdm-codes"
|
| 972 |
+
model_util = mdm_path / "utils" / "model_util.py"
|
| 973 |
+
if not model_util.is_file():
|
| 974 |
+
log.info("_patch_mdm_model_util: %s not found, skipping", model_util)
|
| 975 |
+
return
|
| 976 |
+
|
| 977 |
+
text = model_util.read_text()
|
| 978 |
+
if "def load_saved_model" in text:
|
| 979 |
+
log.info("_patch_mdm_model_util: load_saved_model already present — skip")
|
| 980 |
+
return
|
| 981 |
+
|
| 982 |
+
shim = '''
|
| 983 |
+
|
| 984 |
+
def load_saved_model(model, model_path, use_avg=False, **kwargs):
|
| 985 |
+
"""Compatibility shim: load checkpoint and delegate to load_model_wo_clip.
|
| 986 |
+
|
| 987 |
+
The checkpoint stores only the non-CLIP weights (CLIP weights are
|
| 988 |
+
stripped at training-time save). ``load_model_wo_clip`` loads them
|
| 989 |
+
with ``strict=False`` so the freshly-downloaded CLIP weights in the
|
| 990 |
+
model are preserved.
|
| 991 |
+
"""
|
| 992 |
+
import torch as _torch
|
| 993 |
+
state_dict = _torch.load(model_path, map_location="cpu")
|
| 994 |
+
load_model_wo_clip(model, state_dict)
|
| 995 |
+
'''
|
| 996 |
+
model_util.write_text(text + shim)
|
| 997 |
+
log.info("_patch_mdm_model_util: injected load_saved_model shim into %s", model_util)
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
def _patch_priormdm_source() -> None:
|
| 1001 |
+
"""Mirror comfyui-animoflow/containers/priormdm/Dockerfile lines 46-53.
|
| 1002 |
+
|
| 1003 |
+
priorMDM's upstream source still uses numpy 1.x aliases (np.float, np.int,
|
| 1004 |
+
np.bool) removed in numpy 1.24+, and asserts dataset size > 1 which our
|
| 1005 |
+
single-sample inference path violates. The container's Dockerfile sed-fixes
|
| 1006 |
+
these at image build time; we mirror them here because we run priorMDM
|
| 1007 |
+
inside the orchestrator venv (numpy 2.x), not the container's 1.23 pin.
|
| 1008 |
+
|
| 1009 |
+
Idempotent via a sentinel file. Safe to re-run after each bootstrap.
|
| 1010 |
+
"""
|
| 1011 |
+
import re
|
| 1012 |
+
|
| 1013 |
+
src = EXTERNAL_DIR / "priormdm-codes"
|
| 1014 |
+
if not src.is_dir():
|
| 1015 |
+
log.info("_patch_priormdm_source: %s not found, skipping", src)
|
| 1016 |
+
return
|
| 1017 |
+
sentinel = src / ".animoflow_patched"
|
| 1018 |
+
if sentinel.is_file():
|
| 1019 |
+
log.info("_patch_priormdm_source: already patched (%s) — skip", sentinel)
|
| 1020 |
+
return
|
| 1021 |
+
|
| 1022 |
+
np_alias_subs = [
|
| 1023 |
+
(re.compile(r"\bnp\.float\b"), "float"),
|
| 1024 |
+
(re.compile(r"\bnp\.int\b"), "int"),
|
| 1025 |
+
(re.compile(r"\bnp\.bool\b"), "bool"),
|
| 1026 |
+
]
|
| 1027 |
+
touched = 0
|
| 1028 |
+
for py in src.rglob("*.py"):
|
| 1029 |
+
text = py.read_text()
|
| 1030 |
+
new = text
|
| 1031 |
+
for pat, repl in np_alias_subs:
|
| 1032 |
+
new = pat.sub(repl, new)
|
| 1033 |
+
if new != text:
|
| 1034 |
+
py.write_text(new)
|
| 1035 |
+
touched += 1
|
| 1036 |
+
|
| 1037 |
+
dset = src / "data_loaders" / "humanml" / "data" / "dataset.py"
|
| 1038 |
+
if dset.is_file():
|
| 1039 |
+
t = dset.read_text()
|
| 1040 |
+
t2 = t.replace(
|
| 1041 |
+
"assert len(self.t2m_dataset) > 1",
|
| 1042 |
+
"assert len(self.t2m_dataset) >= 1",
|
| 1043 |
+
)
|
| 1044 |
+
if t2 != t:
|
| 1045 |
+
dset.write_text(t2)
|
| 1046 |
+
|
| 1047 |
+
sentinel.write_text("ok\n")
|
| 1048 |
+
log.info(
|
| 1049 |
+
"_patch_priormdm_source: patched %d .py files + dataset assert in %s",
|
| 1050 |
+
touched, src,
|
| 1051 |
+
)
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
def _patch_blender_bvh_addon():
|
| 1055 |
+
"""Fix Blender's io_anim_bvh addon: 'rU' mode removed in Python 3.12.
|
| 1056 |
+
|
| 1057 |
+
Strategy: create a full copy of Blender's scripts directory at
|
| 1058 |
+
/home/user/app/blender_scripts_patched, fix the BVH addon, and set
|
| 1059 |
+
BLENDER_SYSTEM_SCRIPTS to redirect Blender there.
|
| 1060 |
+
"""
|
| 1061 |
+
import shutil
|
| 1062 |
+
|
| 1063 |
+
sys_scripts = Path("/usr/share/blender/scripts")
|
| 1064 |
+
if not sys_scripts.is_dir():
|
| 1065 |
+
log.info("_patch_blender_bvh_addon: /usr/share/blender/scripts not found, skipping")
|
| 1066 |
+
return
|
| 1067 |
+
|
| 1068 |
+
target_file = sys_scripts / "addons/io_anim_bvh/import_bvh.py"
|
| 1069 |
+
if not target_file.is_file():
|
| 1070 |
+
log.info("_patch_blender_bvh_addon: %s not found, skipping", target_file)
|
| 1071 |
+
return
|
| 1072 |
+
|
| 1073 |
+
# Check if system file has the bug
|
| 1074 |
+
sys_text = target_file.read_text()
|
| 1075 |
+
if "'rU'" not in sys_text and '"rU"' not in sys_text:
|
| 1076 |
+
log.info("_patch_blender_bvh_addon: no 'rU' found in system file, already clean")
|
| 1077 |
+
return
|
| 1078 |
+
|
| 1079 |
+
# Try direct fix first (might work if container allows it)
|
| 1080 |
+
try:
|
| 1081 |
+
fixed = sys_text.replace("'rU'", "'r'").replace('"rU"', '"r"')
|
| 1082 |
+
target_file.write_text(fixed)
|
| 1083 |
+
# Verify
|
| 1084 |
+
if "'rU'" not in target_file.read_text():
|
| 1085 |
+
log.info("_patch_blender_bvh_addon: patched system file directly at %s", target_file)
|
| 1086 |
+
return
|
| 1087 |
+
except PermissionError:
|
| 1088 |
+
log.info("_patch_blender_bvh_addon: no write access to system file, using BLENDER_SYSTEM_SCRIPTS override")
|
| 1089 |
+
|
| 1090 |
+
# Fallback: copy entire scripts dir and set BLENDER_SYSTEM_SCRIPTS
|
| 1091 |
+
patched_dir = Path("/home/user/app/blender_scripts_patched")
|
| 1092 |
+
if patched_dir.is_dir():
|
| 1093 |
+
log.info("_patch_blender_bvh_addon: patched dir already exists at %s", patched_dir)
|
| 1094 |
+
os.environ["BLENDER_SYSTEM_SCRIPTS"] = str(patched_dir)
|
| 1095 |
+
return
|
| 1096 |
+
|
| 1097 |
+
shutil.copytree(sys_scripts, patched_dir)
|
| 1098 |
+
patch_target = patched_dir / "addons/io_anim_bvh/import_bvh.py"
|
| 1099 |
+
text = patch_target.read_text()
|
| 1100 |
+
text = text.replace("'rU'", "'r'").replace('"rU"', '"r"')
|
| 1101 |
+
patch_target.write_text(text)
|
| 1102 |
+
os.environ["BLENDER_SYSTEM_SCRIPTS"] = str(patched_dir)
|
| 1103 |
+
log.info("_patch_blender_bvh_addon: created patched scripts at %s, set BLENDER_SYSTEM_SCRIPTS", patched_dir)
|
| 1104 |
+
|
| 1105 |
+
|
| 1106 |
+
def _ensure_blender_numpy() -> None:
|
| 1107 |
+
"""Install numpy so Blender's glTF2 addon can export GLB.
|
| 1108 |
+
|
| 1109 |
+
On HF Gradio Spaces (Debian Trixie), apt Blender links against
|
| 1110 |
+
python3.11 but the Space's default ``pip3`` targets python3.12+.
|
| 1111 |
+
Running ``pip3 install numpy`` installs for the wrong interpreter.
|
| 1112 |
+
|
| 1113 |
+
Strategy:
|
| 1114 |
+
1. Probe Blender for ``sys.version_info`` to know the Python version.
|
| 1115 |
+
2. Try ``python<ver> -m pip install numpy`` (works on tarball Blender).
|
| 1116 |
+
3. If that fails (apt Blender has no pip for 3.11), install numpy
|
| 1117 |
+
to a ``--target`` directory via the system pip, then set
|
| 1118 |
+
``PYTHONPATH`` so Blender's Python can find it.
|
| 1119 |
+
4. Verify by actually importing numpy inside Blender.
|
| 1120 |
+
|
| 1121 |
+
Idempotent: probes numpy inside Blender first (using sys.exit to
|
| 1122 |
+
get a reliable return code — Blender returns 0 on --python-expr
|
| 1123 |
+
exceptions otherwise).
|
| 1124 |
+
"""
|
| 1125 |
+
import shutil as _shutil
|
| 1126 |
+
import subprocess as _subproc
|
| 1127 |
+
from pathlib import Path as _Path
|
| 1128 |
+
|
| 1129 |
+
blender = os.environ.get("BLENDER_BIN", "").strip() or _shutil.which("blender")
|
| 1130 |
+
if not blender or not _Path(blender).exists():
|
| 1131 |
+
log.warning("Blender not found — skipping numpy install for GLB export")
|
| 1132 |
+
return
|
| 1133 |
+
|
| 1134 |
+
# --- Step 1: probe Blender for numpy + Python version ---
|
| 1135 |
+
# Use sys.exit(code) to get a reliable return code from Blender.
|
| 1136 |
+
probe_script = (
|
| 1137 |
+
"import sys\n"
|
| 1138 |
+
"try:\n"
|
| 1139 |
+
" import numpy; print('NUMPY_OK', numpy.__version__); sys.exit(0)\n"
|
| 1140 |
+
"except ImportError:\n"
|
| 1141 |
+
" print('NUMPY_MISSING')\n"
|
| 1142 |
+
" print('PYVER', f'{sys.version_info.major}.{sys.version_info.minor}')\n"
|
| 1143 |
+
" sys.exit(42)\n"
|
| 1144 |
+
)
|
| 1145 |
+
probe = _subproc.run(
|
| 1146 |
+
[blender, "--background", "--python-expr", probe_script],
|
| 1147 |
+
capture_output=True, text=True, timeout=30,
|
| 1148 |
+
)
|
| 1149 |
+
# Parse stdout for our markers
|
| 1150 |
+
probe_data: dict[str, str] = {}
|
| 1151 |
+
for line in probe.stdout.splitlines():
|
| 1152 |
+
parts = line.split(maxsplit=1)
|
| 1153 |
+
if len(parts) >= 1 and parts[0] in ("NUMPY_OK", "NUMPY_MISSING", "PYVER"):
|
| 1154 |
+
probe_data[parts[0]] = parts[1].strip() if len(parts) == 2 else ""
|
| 1155 |
+
|
| 1156 |
+
if "NUMPY_OK" in probe_data:
|
| 1157 |
+
log.info("Blender numpy: already present (numpy %s, skip)",
|
| 1158 |
+
probe_data["NUMPY_OK"])
|
| 1159 |
+
return
|
| 1160 |
+
|
| 1161 |
+
py_ver = probe_data.get("PYVER", "")
|
| 1162 |
+
log.info("Blender Python version: %s, numpy missing — installing", py_ver)
|
| 1163 |
+
|
| 1164 |
+
# --- Step 2: get pip working for Blender's Python, then install numpy ---
|
| 1165 |
+
blender_python = _shutil.which(f"python{py_ver}") if py_ver else None
|
| 1166 |
+
if not blender_python and py_ver:
|
| 1167 |
+
candidate = f"/usr/bin/python{py_ver}"
|
| 1168 |
+
if _Path(candidate).is_file():
|
| 1169 |
+
blender_python = candidate
|
| 1170 |
+
if not blender_python:
|
| 1171 |
+
log.warning("Cannot find python%s — skipping numpy install", py_ver)
|
| 1172 |
+
return
|
| 1173 |
+
|
| 1174 |
+
log.info("Blender's Python: %s", blender_python)
|
| 1175 |
+
installed = False
|
| 1176 |
+
|
| 1177 |
+
# Strategy A: try ensurepip + pip install (works on tarball Blender)
|
| 1178 |
+
_subproc.run(
|
| 1179 |
+
[blender_python, "-m", "ensurepip", "--upgrade"],
|
| 1180 |
+
capture_output=True, text=True, timeout=120,
|
| 1181 |
+
)
|
| 1182 |
+
direct = _subproc.run(
|
| 1183 |
+
[blender_python, "-m", "pip", "install", "--no-cache-dir",
|
| 1184 |
+
"--break-system-packages", "numpy"],
|
| 1185 |
+
capture_output=True, text=True, timeout=300,
|
| 1186 |
+
)
|
| 1187 |
+
if direct.returncode == 0:
|
| 1188 |
+
log.info("Installed numpy via %s -m pip", blender_python)
|
| 1189 |
+
installed = True
|
| 1190 |
+
|
| 1191 |
+
# Strategy B: bootstrap pip for this Python via get-pip.py, then install
|
| 1192 |
+
if not installed:
|
| 1193 |
+
log.info("%s has no pip — bootstrapping via get-pip.py", blender_python)
|
| 1194 |
+
import urllib.request
|
| 1195 |
+
get_pip = "/tmp/get-pip.py"
|
| 1196 |
+
try:
|
| 1197 |
+
urllib.request.urlretrieve(
|
| 1198 |
+
"https://bootstrap.pypa.io/get-pip.py", get_pip,
|
| 1199 |
+
)
|
| 1200 |
+
except Exception as e:
|
| 1201 |
+
log.warning("Failed to download get-pip.py: %s", e)
|
| 1202 |
+
# Continue to Strategy C
|
| 1203 |
+
else:
|
| 1204 |
+
bootstrap_pip = _subproc.run(
|
| 1205 |
+
[blender_python, get_pip, "--break-system-packages"],
|
| 1206 |
+
capture_output=True, text=True, timeout=120,
|
| 1207 |
+
)
|
| 1208 |
+
if bootstrap_pip.returncode == 0:
|
| 1209 |
+
install_np = _subproc.run(
|
| 1210 |
+
[blender_python, "-m", "pip", "install", "--no-cache-dir",
|
| 1211 |
+
"--break-system-packages", "numpy"],
|
| 1212 |
+
capture_output=True, text=True, timeout=300,
|
| 1213 |
+
)
|
| 1214 |
+
if install_np.returncode == 0:
|
| 1215 |
+
log.info("Installed numpy via get-pip.py + %s -m pip", blender_python)
|
| 1216 |
+
installed = True
|
| 1217 |
+
else:
|
| 1218 |
+
log.info("pip install after get-pip failed (rc=%s): %s",
|
| 1219 |
+
install_np.returncode, (install_np.stderr or "")[-300:])
|
| 1220 |
+
else:
|
| 1221 |
+
log.info("get-pip.py failed (rc=%s): %s",
|
| 1222 |
+
bootstrap_pip.returncode, (bootstrap_pip.stderr or "")[-300:])
|
| 1223 |
+
|
| 1224 |
+
# Strategy C: pip download wheels for the correct python version, install with --target
|
| 1225 |
+
if not installed:
|
| 1226 |
+
log.info("Trying pip download with --python-version %s ...", py_ver)
|
| 1227 |
+
pip3 = _shutil.which("pip3") or _shutil.which("pip")
|
| 1228 |
+
if pip3:
|
| 1229 |
+
dl_dir = _Path("/tmp/numpy_wheels")
|
| 1230 |
+
dl_dir.mkdir(parents=True, exist_ok=True)
|
| 1231 |
+
target_dir = _Path("/home/user/app/blender_numpy_packages")
|
| 1232 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 1233 |
+
|
| 1234 |
+
dl = _subproc.run(
|
| 1235 |
+
[pip3, "download", "--no-cache-dir",
|
| 1236 |
+
"--python-version", py_ver,
|
| 1237 |
+
"--abi", f"cp{py_ver.replace('.', '')}",
|
| 1238 |
+
"--platform", "manylinux2014_x86_64",
|
| 1239 |
+
"--only-binary=:all:",
|
| 1240 |
+
"-d", str(dl_dir), "numpy"],
|
| 1241 |
+
capture_output=True, text=True, timeout=120,
|
| 1242 |
+
)
|
| 1243 |
+
if dl.returncode == 0:
|
| 1244 |
+
wheels = list(dl_dir.glob("*.whl"))
|
| 1245 |
+
if wheels:
|
| 1246 |
+
inst = _subproc.run(
|
| 1247 |
+
[pip3, "install", "--no-deps", "--no-cache-dir",
|
| 1248 |
+
"--break-system-packages",
|
| 1249 |
+
"--target", str(target_dir)] + [str(w) for w in wheels],
|
| 1250 |
+
capture_output=True, text=True, timeout=120,
|
| 1251 |
+
)
|
| 1252 |
+
if inst.returncode == 0:
|
| 1253 |
+
existing = os.environ.get("PYTHONPATH", "")
|
| 1254 |
+
os.environ["PYTHONPATH"] = (
|
| 1255 |
+
f"{target_dir}:{existing}" if existing else str(target_dir)
|
| 1256 |
+
)
|
| 1257 |
+
log.info("Installed numpy wheels to %s, PYTHONPATH=%s",
|
| 1258 |
+
target_dir, os.environ["PYTHONPATH"])
|
| 1259 |
+
installed = True
|
| 1260 |
+
|
| 1261 |
+
if not installed:
|
| 1262 |
+
log.warning("All numpy install strategies failed for python%s", py_ver)
|
| 1263 |
+
return
|
| 1264 |
+
|
| 1265 |
+
# --- Step 4: verify inside Blender ---
|
| 1266 |
+
verify_script = (
|
| 1267 |
+
"import sys\n"
|
| 1268 |
+
"try:\n"
|
| 1269 |
+
" import numpy; print('VERIFY_OK', numpy.__version__); sys.exit(0)\n"
|
| 1270 |
+
"except ImportError as e:\n"
|
| 1271 |
+
" print('VERIFY_FAIL', e); sys.exit(1)\n"
|
| 1272 |
+
)
|
| 1273 |
+
confirm = _subproc.run(
|
| 1274 |
+
[blender, "--background", "--python-expr", verify_script],
|
| 1275 |
+
capture_output=True, text=True, timeout=30,
|
| 1276 |
+
)
|
| 1277 |
+
for line in confirm.stdout.splitlines():
|
| 1278 |
+
if line.startswith("VERIFY_OK"):
|
| 1279 |
+
log.info("Blender numpy install verified: %s", line)
|
| 1280 |
+
return
|
| 1281 |
+
if line.startswith("VERIFY_FAIL"):
|
| 1282 |
+
log.warning("Blender numpy verification failed: %s", line)
|
| 1283 |
+
return
|
| 1284 |
+
log.warning("Blender numpy verification inconclusive (rc=%s, stderr=%s)",
|
| 1285 |
+
confirm.returncode, (confirm.stderr or "")[-300:])
|
| 1286 |
+
|
| 1287 |
+
|
| 1288 |
+
_CONTAINER_SPECS_LOGGED = False
|
| 1289 |
+
|
| 1290 |
+
|
| 1291 |
+
def _log_container_specs() -> None:
|
| 1292 |
+
"""Log CPU/RAM/Blender specs once per process for ZeroGPU diagnostics."""
|
| 1293 |
+
global _CONTAINER_SPECS_LOGGED
|
| 1294 |
+
if _CONTAINER_SPECS_LOGGED:
|
| 1295 |
+
return
|
| 1296 |
+
_CONTAINER_SPECS_LOGGED = True
|
| 1297 |
+
|
| 1298 |
+
import multiprocessing
|
| 1299 |
+
import time
|
| 1300 |
+
|
| 1301 |
+
specs: dict[str, str] = {}
|
| 1302 |
+
|
| 1303 |
+
# CPU counts
|
| 1304 |
+
specs["mp_cpu_count"] = str(multiprocessing.cpu_count())
|
| 1305 |
+
specs["os_cpu_count"] = str(os.cpu_count())
|
| 1306 |
+
|
| 1307 |
+
# RAM
|
| 1308 |
+
try:
|
| 1309 |
+
page = os.sysconf("SC_PAGE_SIZE")
|
| 1310 |
+
pages = os.sysconf("SC_PHYS_PAGES")
|
| 1311 |
+
specs["total_ram_gb"] = f"{page * pages / 1024**3:.2f}"
|
| 1312 |
+
except (ValueError, OSError):
|
| 1313 |
+
specs["total_ram_gb"] = "unknown"
|
| 1314 |
+
|
| 1315 |
+
try:
|
| 1316 |
+
with open("/proc/meminfo") as f:
|
| 1317 |
+
for line in f:
|
| 1318 |
+
if line.startswith("MemAvailable:"):
|
| 1319 |
+
kb = int(line.split()[1])
|
| 1320 |
+
specs["avail_ram_gb"] = f"{kb / 1024**2:.2f}"
|
| 1321 |
+
break
|
| 1322 |
+
else:
|
| 1323 |
+
specs["avail_ram_gb"] = "unknown"
|
| 1324 |
+
except OSError:
|
| 1325 |
+
specs["avail_ram_gb"] = "unknown"
|
| 1326 |
+
|
| 1327 |
+
# CPU model
|
| 1328 |
+
try:
|
| 1329 |
+
with open("/proc/cpuinfo") as f:
|
| 1330 |
+
for line in f:
|
| 1331 |
+
if line.startswith("model name"):
|
| 1332 |
+
specs["cpu_model"] = line.split(":", 1)[1].strip()
|
| 1333 |
+
break
|
| 1334 |
+
else:
|
| 1335 |
+
specs["cpu_model"] = "unknown"
|
| 1336 |
+
except OSError:
|
| 1337 |
+
specs["cpu_model"] = "unknown"
|
| 1338 |
+
|
| 1339 |
+
# Blender cold-start time
|
| 1340 |
+
import shutil as _shutil
|
| 1341 |
+
blender = os.environ.get("BLENDER_BIN", "").strip() or _shutil.which("blender")
|
| 1342 |
+
if blender and Path(blender).exists():
|
| 1343 |
+
try:
|
| 1344 |
+
t0 = time.monotonic()
|
| 1345 |
+
subprocess.run(
|
| 1346 |
+
[blender, "--version"],
|
| 1347 |
+
capture_output=True, text=True, timeout=60,
|
| 1348 |
+
)
|
| 1349 |
+
specs["blender_startup_s"] = f"{time.monotonic() - t0:.3f}"
|
| 1350 |
+
except Exception as e:
|
| 1351 |
+
specs["blender_startup_s"] = f"error:{e}"
|
| 1352 |
+
else:
|
| 1353 |
+
specs["blender_startup_s"] = "not_found"
|
| 1354 |
+
|
| 1355 |
+
# cgroup limits (ZeroGPU containers use cgroup v2)
|
| 1356 |
+
try:
|
| 1357 |
+
with open("/sys/fs/cgroup/cpu.max") as f:
|
| 1358 |
+
specs["cgroup_cpu_max"] = f.read().strip()
|
| 1359 |
+
except Exception:
|
| 1360 |
+
specs["cgroup_cpu_max"] = "unavailable"
|
| 1361 |
+
try:
|
| 1362 |
+
with open("/sys/fs/cgroup/memory.max") as f:
|
| 1363 |
+
raw = f.read().strip()
|
| 1364 |
+
if raw == "max":
|
| 1365 |
+
specs["cgroup_mem_max"] = "max (unlimited)"
|
| 1366 |
+
else:
|
| 1367 |
+
specs["cgroup_mem_max"] = f"{int(raw) / 1024**3:.2f}GB"
|
| 1368 |
+
except Exception:
|
| 1369 |
+
specs["cgroup_mem_max"] = "unavailable"
|
| 1370 |
+
|
| 1371 |
+
parts = " | ".join(f"{k}={v}" for k, v in specs.items())
|
| 1372 |
+
log.info("[CONTAINER_SPECS] %s", parts)
|
| 1373 |
+
|
| 1374 |
+
|
| 1375 |
+
def bootstrap_external_repos() -> None:
|
| 1376 |
+
"""Idempotent. Clone external repos + download checkpoints if not
|
| 1377 |
+
already present. No-op in Docker mode."""
|
| 1378 |
+
if _docker_mode():
|
| 1379 |
+
log.info("Docker mode detected (/opt/* paths present) — skip bootstrap")
|
| 1380 |
+
return
|
| 1381 |
+
|
| 1382 |
+
log.info("Bootstrap mode (HF Gradio or fresh OSS-local) — base=%s", EXTERNAL_DIR)
|
| 1383 |
+
|
| 1384 |
+
gh_token = os.environ.get("gh_token") or os.environ.get("GH_TOKEN")
|
| 1385 |
+
hf_token = os.environ.get("hf_token") or os.environ.get("HF_TOKEN")
|
| 1386 |
+
|
| 1387 |
+
_install_blender_portable()
|
| 1388 |
+
_install_gltfpack()
|
| 1389 |
+
_clone_all(gh_token)
|
| 1390 |
+
_download_checkpoints(hf_token)
|
| 1391 |
+
_download_private_characters(hf_token)
|
| 1392 |
+
_set_env_for_external()
|
| 1393 |
+
_patch_mdm_model_util()
|
| 1394 |
+
_patch_priormdm_source()
|
| 1395 |
+
_patch_blender_bvh_addon()
|
| 1396 |
+
_ensure_blender_numpy()
|
| 1397 |
+
# Kimodo escape-hatch venv build runs in a background thread so the Gradio
|
| 1398 |
+
# UI comes up promptly with MDM/MoMask. ENABLE_KIMODO=false disables (kill
|
| 1399 |
+
# switch). Per the no-silent-fallback rule: any failure writes the
|
| 1400 |
+
# .kimodo_failed sentinel and is surfaced to the UI; we never silently
|
| 1401 |
+
# degrade.
|
| 1402 |
+
if os.environ.get("ENABLE_KIMODO", "true").strip().lower() != "false":
|
| 1403 |
+
_set_env_for_kimodo()
|
| 1404 |
+
_install_kimodo_async(hf_token)
|
| 1405 |
+
else:
|
| 1406 |
+
log.info("ENABLE_KIMODO=false — Kimodo bootstrap skipped")
|
| 1407 |
+
_log_container_specs()
|
| 1408 |
+
log.info("Bootstrap complete: external repos at %s", EXTERNAL_DIR)
|
| 1409 |
+
|
| 1410 |
+
|
| 1411 |
+
if __name__ == "__main__":
|
| 1412 |
+
logging.basicConfig(
|
| 1413 |
+
level="INFO",
|
| 1414 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 1415 |
+
)
|
| 1416 |
+
bootstrap_external_repos()
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# animoflow-app entrypoint — boots the orchestrator inside the HF Space (or
|
| 3 |
+
# any host running this image). Idempotent.
|
| 4 |
+
#
|
| 5 |
+
# DELIBERATE: not `set -e`. Weight download and Blender health-check are
|
| 6 |
+
# best-effort; the orchestrator must start even if either fails. /v1/health
|
| 7 |
+
# and /v1/tasks work without weights or Blender; inference / rig / GLB
|
| 8 |
+
# routes will surface clear errors at request time if their deps are
|
| 9 |
+
# missing. Better than dying silently in a Space we can't easily debug.
|
| 10 |
+
set -uo pipefail
|
| 11 |
+
|
| 12 |
+
cd /opt/animoflow-app
|
| 13 |
+
|
| 14 |
+
# 1. Download MDM checkpoints if missing. download_weights.sh is idempotent
|
| 15 |
+
# AND best-effort: a non-zero exit logs a warning but does not abort
|
| 16 |
+
# boot. Set SKIP_CHECKPOINTS=1 to skip the download step entirely.
|
| 17 |
+
if [ "${SKIP_CHECKPOINTS:-0}" != "1" ] && [ -x /opt/animoflow-app/scripts/download_weights.sh ]; then
|
| 18 |
+
/opt/animoflow-app/scripts/download_weights.sh \
|
| 19 |
+
|| echo "[entrypoint] WARN: weight download script returned non-zero. Orchestrator will boot anyway; inference will fail with a clear 'checkpoint missing' error until weights are present."
|
| 20 |
+
fi
|
| 21 |
+
|
| 22 |
+
# 2. Health-check Blender (warn only — do not abort).
|
| 23 |
+
if ! "${BLENDER_BIN:-/opt/blender/blender}" --version >/dev/null 2>&1; then
|
| 24 |
+
echo "[entrypoint] WARN: Blender not found at ${BLENDER_BIN:-/opt/blender/blender}"
|
| 25 |
+
echo "[entrypoint] Rig + GLB stages will fail. Inference-only requests still work."
|
| 26 |
+
fi
|
| 27 |
+
|
| 28 |
+
# 3. Launch FastAPI + Gradio. uvicorn loads fastapi_app from app.py.
|
| 29 |
+
exec /opt/venvs/api/bin/uvicorn \
|
| 30 |
+
--app-dir /opt/animoflow-app \
|
| 31 |
+
--host 0.0.0.0 \
|
| 32 |
+
--port "${PORT:-7860}" \
|
| 33 |
+
app:fastapi_app
|
escape_hatch/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Escape hatch for outlier-model venvs.
|
| 3 |
+
|
| 4 |
+
Models whose deps don't merge cleanly with the orchestrator venv (e.g.
|
| 5 |
+
Kimodo's NVIDIA stack, future Tier-2 models) live in their own
|
| 6 |
+
`/opt/venvs/comfy-X/` venv inside the same Docker image. The
|
| 7 |
+
@spaces.GPU-decorated `run_inference` function in pipeline_hf.py
|
| 8 |
+
delegates to these via subprocess — the subprocess inherits CUDA
|
| 9 |
+
visibility from the GPU fork and pays cold-start (~15s) for full dep
|
| 10 |
+
isolation.
|
| 11 |
+
|
| 12 |
+
The registry currently carries Kimodo; adding further outlier models
|
| 13 |
+
is purely additive — no orchestrator changes.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from .invoke import is_outlier, invoke
|
| 17 |
+
|
| 18 |
+
__all__ = ["is_outlier", "invoke"]
|
escape_hatch/invoke.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Subprocess-based invocation for outlier-model venvs.
|
| 3 |
+
|
| 4 |
+
For each outlier model, we maintain:
|
| 5 |
+
/opt/venvs/comfy-<model>/ — its isolated Python venv (built at Docker time)
|
| 6 |
+
scripts/run_inference_<model>.py — a tiny CLI we own that calls the
|
| 7 |
+
model's wrapper code with JSON args
|
| 8 |
+
|
| 9 |
+
The decorated @spaces.GPU function in pipeline_hf.py calls invoke(model, …)
|
| 10 |
+
which spawns the subprocess. CUDA visibility is inherited via the fork
|
| 11 |
+
parent → subprocess env chain.
|
| 12 |
+
|
| 13 |
+
Adding an outlier model: add an entry to _OUTLIERS and ship the
|
| 14 |
+
matching CLI script.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import os
|
| 22 |
+
import subprocess
|
| 23 |
+
import threading
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Map model name → {venv_python, runner_script}.
|
| 31 |
+
# Paths are env-overridable so bootstrap.py (HF Gradio mode) and the Dockerfile
|
| 32 |
+
# (OSS local Docker mode) can plant them at the locations they actually build.
|
| 33 |
+
_OUTLIERS: dict[str, dict[str, str]] = {
|
| 34 |
+
"kimodo": {
|
| 35 |
+
"venv_python": os.environ.get(
|
| 36 |
+
"KIMODO_VENV_PYTHON", "/home/user/app/venvs/kimodo/bin/python",
|
| 37 |
+
),
|
| 38 |
+
"runner_script": os.environ.get(
|
| 39 |
+
"KIMODO_RUNNER_SCRIPT",
|
| 40 |
+
"/home/user/app/scripts/run_inference_kimodo.py",
|
| 41 |
+
),
|
| 42 |
+
},
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# Per-model subprocess timeout (seconds). Kimodo's first call after a Space
|
| 46 |
+
# restart pays the full model load (~120 s including LLaMA encode warmup);
|
| 47 |
+
# warm calls are ~10-15 s. The matching @spaces.GPU(duration=…) lives in
|
| 48 |
+
# pipeline_hf._run_inference_gpu_kimodo.
|
| 49 |
+
_TIMEOUTS: dict[str, int] = {
|
| 50 |
+
"kimodo": 300,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Set by bootstrap._install_kimodo_async once the venv build finishes. The
|
| 54 |
+
# runner script also reads the .kimodo_ready sentinel as a backup, so this is
|
| 55 |
+
# defence-in-depth (the event lets us fail fast without spawning a subprocess
|
| 56 |
+
# that would immediately exit with "venv not ready").
|
| 57 |
+
_KIMODO_READY: threading.Event | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _external_dir() -> Path:
|
| 61 |
+
"""Mirror bootstrap.EXTERNAL_DIR without importing bootstrap (which would
|
| 62 |
+
re-run import-time work)."""
|
| 63 |
+
return Path(
|
| 64 |
+
os.environ.get(
|
| 65 |
+
"ANIMOFLOW_EXTERNAL_DIR",
|
| 66 |
+
"/home/user/app/external"
|
| 67 |
+
if os.path.isdir("/home/user/app")
|
| 68 |
+
else str(Path(__file__).resolve().parent.parent / "external"),
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _kimodo_failed_message() -> str | None:
|
| 74 |
+
"""Return the captured Kimodo bootstrap error, or None if no .kimodo_failed."""
|
| 75 |
+
sentinel = _external_dir() / ".kimodo_failed"
|
| 76 |
+
if not sentinel.is_file():
|
| 77 |
+
return None
|
| 78 |
+
try:
|
| 79 |
+
return sentinel.read_text()[:1200]
|
| 80 |
+
except OSError:
|
| 81 |
+
return "(unreadable .kimodo_failed sentinel)"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_outlier(model: str) -> bool:
|
| 85 |
+
"""True if the model is registered as an outlier and should be invoked
|
| 86 |
+
via subprocess instead of the in-orchestrator registry."""
|
| 87 |
+
return model in _OUTLIERS
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def invoke(
|
| 91 |
+
model: str,
|
| 92 |
+
*,
|
| 93 |
+
prompt: str,
|
| 94 |
+
num_frames: int,
|
| 95 |
+
seed: int,
|
| 96 |
+
guidance_param: float | None = None,
|
| 97 |
+
timeout: int | None = None,
|
| 98 |
+
**extra: Any,
|
| 99 |
+
) -> bytes:
|
| 100 |
+
"""Invoke an outlier model via subprocess. Returns NPZ bytes.
|
| 101 |
+
|
| 102 |
+
The subprocess inherits CUDA_VISIBLE_DEVICES from the parent process —
|
| 103 |
+
on HF this is the GPU fork created by @spaces.GPU. The subprocess
|
| 104 |
+
re-initializes CUDA cleanly because it's a fresh Python process (spawn-
|
| 105 |
+
style isolation), avoiding the "Cannot re-initialize CUDA in forked
|
| 106 |
+
subprocess" error that bites multiprocessing.fork.
|
| 107 |
+
|
| 108 |
+
``timeout`` defaults to the per-model value in ``_TIMEOUTS`` (Kimodo: 300 s),
|
| 109 |
+
falling back to 60 s.
|
| 110 |
+
"""
|
| 111 |
+
if model not in _OUTLIERS:
|
| 112 |
+
raise RuntimeError(
|
| 113 |
+
f"Model {model!r} not registered as an outlier. Known outliers: "
|
| 114 |
+
f"{sorted(_OUTLIERS.keys())}. Add to escape_hatch.invoke._OUTLIERS first."
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
spec = _OUTLIERS[model]
|
| 118 |
+
venv_python = spec["venv_python"]
|
| 119 |
+
runner_script = spec["runner_script"]
|
| 120 |
+
|
| 121 |
+
# Kimodo-specific readiness gate. Bootstrap builds the venv in a background
|
| 122 |
+
# thread; refuse to spawn the subprocess until .kimodo_ready exists (or the
|
| 123 |
+
# ready Event is set). Per the no-silent-fallback policy: if the build
|
| 124 |
+
# FAILED, surface the captured error verbatim.
|
| 125 |
+
if model == "kimodo":
|
| 126 |
+
failed_msg = _kimodo_failed_message()
|
| 127 |
+
if failed_msg is not None:
|
| 128 |
+
raise RuntimeError(
|
| 129 |
+
f"Kimodo venv build FAILED at bootstrap. "
|
| 130 |
+
f"Captured error:\n{failed_msg}"
|
| 131 |
+
)
|
| 132 |
+
ready_sentinel = _external_dir() / ".kimodo_ready"
|
| 133 |
+
if not ready_sentinel.is_file():
|
| 134 |
+
# Block briefly on the in-process Event so a request that lands
|
| 135 |
+
# right at the tail of the build doesn't 503 needlessly.
|
| 136 |
+
event = _KIMODO_READY
|
| 137 |
+
wait_s = int(os.environ.get("KIMODO_READY_WAIT_S", "30"))
|
| 138 |
+
if event is not None and not event.is_set():
|
| 139 |
+
event.wait(timeout=wait_s)
|
| 140 |
+
if not ready_sentinel.is_file() and (event is None or not event.is_set()):
|
| 141 |
+
raise RuntimeError(
|
| 142 |
+
"Kimodo venv still warming up — bootstrap installs deps "
|
| 143 |
+
"and downloads the 16 GB LLaMA-3-8B encoder on first boot. "
|
| 144 |
+
"Typical cold-start is 5-10 minutes; please retry."
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if not Path(venv_python).exists():
|
| 148 |
+
raise RuntimeError(
|
| 149 |
+
f"Outlier venv not found: {venv_python}. "
|
| 150 |
+
f"Was the bootstrap venv builder run? (set ENABLE_KIMODO=true)"
|
| 151 |
+
)
|
| 152 |
+
if not Path(runner_script).exists():
|
| 153 |
+
raise RuntimeError(f"Runner script missing: {runner_script}")
|
| 154 |
+
|
| 155 |
+
payload = {
|
| 156 |
+
"prompt": prompt,
|
| 157 |
+
"num_frames": num_frames,
|
| 158 |
+
"seed": seed,
|
| 159 |
+
"guidance_param": guidance_param,
|
| 160 |
+
**extra,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
effective_timeout = timeout if timeout is not None else _TIMEOUTS.get(model, 60)
|
| 164 |
+
|
| 165 |
+
log.info(
|
| 166 |
+
"Invoking outlier %r via %s (timeout=%ds, cwd-isolated subprocess)",
|
| 167 |
+
model,
|
| 168 |
+
venv_python,
|
| 169 |
+
effective_timeout,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# Pass payload via stdin to avoid shell-quoting nightmares
|
| 173 |
+
result = subprocess.run(
|
| 174 |
+
[venv_python, runner_script],
|
| 175 |
+
input=json.dumps(payload).encode(),
|
| 176 |
+
capture_output=True,
|
| 177 |
+
timeout=effective_timeout,
|
| 178 |
+
env={**os.environ}, # inherit CUDA_VISIBLE_DEVICES from GPU fork
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if result.returncode != 0:
|
| 182 |
+
raise RuntimeError(
|
| 183 |
+
f"Outlier {model!r} subprocess exited {result.returncode}. "
|
| 184 |
+
f"stderr (last 400 chars): {result.stderr.decode(errors='replace')[-400:]}"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Stdout is the NPZ bytes; stderr carries logs only
|
| 188 |
+
npz_bytes = result.stdout
|
| 189 |
+
if not npz_bytes:
|
| 190 |
+
raise RuntimeError(
|
| 191 |
+
f"Outlier {model!r} produced empty NPZ output. "
|
| 192 |
+
f"stderr: {result.stderr.decode(errors='replace')[-400:]}"
|
| 193 |
+
)
|
| 194 |
+
return npz_bytes
|
packages.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blender
|
| 2 |
+
xauth
|
| 3 |
+
xvfb
|
pipeline_hf.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HF pipeline — replaces animoflow-api's ComfyUI-orchestrated `pipeline.run`.
|
| 3 |
+
|
| 4 |
+
Same call signature as animoflow-api/api/pipeline.py:run, so the FastAPI
|
| 5 |
+
in animoflow-api/api/main.py can call this without modification.
|
| 6 |
+
|
| 7 |
+
Compute layout:
|
| 8 |
+
|
| 9 |
+
Receive request (CPU, orchestrator)
|
| 10 |
+
──► run_inference(...) (GPU, @spaces.GPU(duration=30))
|
| 11 |
+
└── npz bytes
|
| 12 |
+
──► resample (CPU, shared stage library)
|
| 13 |
+
──► IK npz → bvh (CPU — momask Joint2BVHConvertor)
|
| 14 |
+
──► Blender bvh → fbx (CPU subprocess)
|
| 15 |
+
──► Blender fbx → glb (CPU subprocess, shared stage library script)
|
| 16 |
+
──► return filename
|
| 17 |
+
|
| 18 |
+
Only run_inference is GPU-billed. Everything else stays in the persistent
|
| 19 |
+
orchestrator process. ZeroGPU daily quota burns at ~10-15s per request,
|
| 20 |
+
not 60-120s.
|
| 21 |
+
|
| 22 |
+
PIPELINE SHAPE AND STAGE SEMANTICS ARE SHARED, NOT DUPLICATED: this
|
| 23 |
+
executor walks the plan from comfyui-animoflow/animoflow_stages/plan.py
|
| 24 |
+
— the exact plan the API server compiles into the local ComfyUI node
|
| 25 |
+
DAG. Per-model parameter mapping (genparams), the resample stage, and
|
| 26 |
+
the FBX→GLB Blender script (snap-to-ground, trajectory restore, Y_bot
|
| 27 |
+
brand tint, matte-character flatten, keyframe RDP) all come from the stage
|
| 28 |
+
library, so the hosted pipeline and the local graph cannot drift.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import importlib.util
|
| 34 |
+
import json
|
| 35 |
+
import logging
|
| 36 |
+
import os
|
| 37 |
+
import time
|
| 38 |
+
import traceback
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from typing import Any, Callable
|
| 41 |
+
|
| 42 |
+
# Make sure the orchestrator's own modules are importable when this module
|
| 43 |
+
# is loaded by uvicorn from a different cwd.
|
| 44 |
+
import sys
|
| 45 |
+
|
| 46 |
+
_THIS_DIR = Path(__file__).resolve().parent
|
| 47 |
+
if str(_THIS_DIR) not in sys.path:
|
| 48 |
+
sys.path.insert(0, str(_THIS_DIR))
|
| 49 |
+
|
| 50 |
+
from spaces_compat import GPU # noqa: E402
|
| 51 |
+
|
| 52 |
+
log = logging.getLogger(__name__)
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# Output dir — match animoflow-api's OUTPUT_DIR convention
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
_OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/tmp/animoflow-output"))
|
| 59 |
+
_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
# comfyui-animoflow checkout (cloned at Docker build time on the Space).
|
| 62 |
+
_COMFY_ROOT = Path(
|
| 63 |
+
os.environ.get("COMFYUI_ANIMOFLOW_DIR", "/opt/comfyui-animoflow")
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# Lazy imports — defer heavy modules so app.py boots cheaply for tests
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
_retargeter = None
|
| 71 |
+
_post_funcs = None
|
| 72 |
+
_stage_lib = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _stages():
|
| 76 |
+
"""Load the shared stage library from the comfyui-animoflow checkout
|
| 77 |
+
(cached). Path-based import — the repo isn't pip-installed."""
|
| 78 |
+
global _stage_lib
|
| 79 |
+
if _stage_lib is None:
|
| 80 |
+
pkg_init = _COMFY_ROOT / "animoflow_stages" / "__init__.py"
|
| 81 |
+
if not pkg_init.is_file():
|
| 82 |
+
raise RuntimeError(
|
| 83 |
+
f"animoflow_stages not found at {pkg_init}. Was "
|
| 84 |
+
"comfyui-animoflow cloned at Docker build time (and is the "
|
| 85 |
+
"clone new enough to contain the stage library)?"
|
| 86 |
+
)
|
| 87 |
+
# Purge any stale entries first — `from animoflow_stages import x`
|
| 88 |
+
# binds cached submodules WITHOUT attaching them to a freshly
|
| 89 |
+
# inserted package object, leaving `stages.plan` unset.
|
| 90 |
+
for k in [k for k in sys.modules
|
| 91 |
+
if k == "animoflow_stages" or k.startswith("animoflow_stages.")]:
|
| 92 |
+
del sys.modules[k]
|
| 93 |
+
spec = importlib.util.spec_from_file_location(
|
| 94 |
+
"animoflow_stages", str(pkg_init),
|
| 95 |
+
submodule_search_locations=[str(pkg_init.parent)])
|
| 96 |
+
mod = importlib.util.module_from_spec(spec)
|
| 97 |
+
sys.modules["animoflow_stages"] = mod
|
| 98 |
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
| 99 |
+
from animoflow_stages import ( # noqa: F401 — register submodules
|
| 100 |
+
fps, genparams, glb_export, glb_post, plan, resample,
|
| 101 |
+
)
|
| 102 |
+
_stage_lib = sys.modules["animoflow_stages"]
|
| 103 |
+
return _stage_lib
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _install_numpy_compat_shim() -> None:
|
| 107 |
+
"""Make momask-codes' legacy numpy 1.x calls work under numpy 2.x.
|
| 108 |
+
|
| 109 |
+
momask-codes was written against numpy < 1.24 and uses two APIs that
|
| 110 |
+
have since been removed:
|
| 111 |
+
|
| 112 |
+
* `np.float` / `np.int` / `np.bool` / `np.object` — removed in numpy 1.24.
|
| 113 |
+
Used in `momask-codes/common/quaternion.py` and similar.
|
| 114 |
+
* `numpy.core.umath_tests` — internal module removed in numpy 2.0.
|
| 115 |
+
Used by `momask-codes/visualization/Animation.py` for its `inner1d`
|
| 116 |
+
helper. We replace it with a tiny shim that uses `np.einsum`.
|
| 117 |
+
|
| 118 |
+
Per the wrap-don't-modify rule we do not patch momask-codes itself.
|
| 119 |
+
Instead we install these shims in our orchestrator process BEFORE the
|
| 120 |
+
first momask import. Idempotent + no-op on numpy < 2.0.
|
| 121 |
+
"""
|
| 122 |
+
import warnings as _warnings # noqa: I001
|
| 123 |
+
|
| 124 |
+
import numpy as _np
|
| 125 |
+
|
| 126 |
+
# 1. np.float / np.int / np.bool / np.object aliases.
|
| 127 |
+
# `hasattr` triggers a numpy FutureWarning on some legacy attrs even
|
| 128 |
+
# though the attribute itself doesn't exist as a real symbol — silence
|
| 129 |
+
# those during the probe so they don't pollute orchestrator startup.
|
| 130 |
+
with _warnings.catch_warnings():
|
| 131 |
+
_warnings.simplefilter("ignore", FutureWarning)
|
| 132 |
+
_warnings.simplefilter("ignore", DeprecationWarning)
|
| 133 |
+
for _legacy, _replacement in (
|
| 134 |
+
("float", _np.float64),
|
| 135 |
+
("int", _np.int64),
|
| 136 |
+
("bool", _np.bool_),
|
| 137 |
+
("object", _np.object_),
|
| 138 |
+
("complex", _np.complex128),
|
| 139 |
+
("str", _np.str_),
|
| 140 |
+
("long", _np.int64),
|
| 141 |
+
):
|
| 142 |
+
if not hasattr(_np, _legacy):
|
| 143 |
+
setattr(_np, _legacy, _replacement)
|
| 144 |
+
|
| 145 |
+
# 2. numpy.core.umath_tests stub. The original module exposed several
|
| 146 |
+
# ufunc helpers that momask uses; we provide drop-ins for all known
|
| 147 |
+
# callers and surface a clear error for anything else so we discover
|
| 148 |
+
# gaps via a useful exception, not a cryptic AttributeError.
|
| 149 |
+
if "numpy.core.umath_tests" not in sys.modules:
|
| 150 |
+
import types as _types
|
| 151 |
+
|
| 152 |
+
_stub = _types.ModuleType("numpy.core.umath_tests")
|
| 153 |
+
|
| 154 |
+
def _inner1d(a, b):
|
| 155 |
+
"""np.core.umath_tests.inner1d → einsum element-wise dot."""
|
| 156 |
+
return _np.einsum("...i,...i->...", a, b)
|
| 157 |
+
|
| 158 |
+
def _matrix_multiply(a, b):
|
| 159 |
+
"""np.core.umath_tests.matrix_multiply → np.matmul (handles
|
| 160 |
+
stacked matrices the same way the old C ufunc did)."""
|
| 161 |
+
return _np.matmul(a, b)
|
| 162 |
+
|
| 163 |
+
def _innerwt(a, b, w):
|
| 164 |
+
"""np.core.umath_tests.innerwt → weighted inner product."""
|
| 165 |
+
return _np.einsum("...i,...i,...i->...", a, b, w)
|
| 166 |
+
|
| 167 |
+
_stub.inner1d = _inner1d
|
| 168 |
+
_stub.matrix_multiply = _matrix_multiply
|
| 169 |
+
_stub.innerwt = _innerwt
|
| 170 |
+
sys.modules["numpy.core.umath_tests"] = _stub
|
| 171 |
+
# Also attach to numpy.core so `from numpy.core import umath_tests`
|
| 172 |
+
# style imports resolve.
|
| 173 |
+
try:
|
| 174 |
+
import numpy.core as _np_core
|
| 175 |
+
|
| 176 |
+
if not hasattr(_np_core, "umath_tests"):
|
| 177 |
+
_np_core.umath_tests = _stub
|
| 178 |
+
except ImportError:
|
| 179 |
+
pass
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _get_retargeter():
|
| 183 |
+
"""Lazy-load the comfyui-animoflow MotionRetargeter singleton.
|
| 184 |
+
|
| 185 |
+
Loads the upstream momask Joint2BVHConvertor under the hood. Pure-Python
|
| 186 |
+
(numpy / matplotlib / scipy / torch) — no GPU required for IK or BVH.
|
| 187 |
+
"""
|
| 188 |
+
global _retargeter
|
| 189 |
+
if _retargeter is None:
|
| 190 |
+
# Install numpy-compat shim BEFORE importing anything from momask.
|
| 191 |
+
_install_numpy_compat_shim()
|
| 192 |
+
|
| 193 |
+
nodes_dir = Path(
|
| 194 |
+
os.environ.get(
|
| 195 |
+
"COMFYUI_ANIMOFLOW_NODES_DIR",
|
| 196 |
+
str(_COMFY_ROOT / "nodes"),
|
| 197 |
+
)
|
| 198 |
+
)
|
| 199 |
+
if not nodes_dir.is_dir():
|
| 200 |
+
raise RuntimeError(
|
| 201 |
+
f"comfyui-animoflow/nodes/ not found at {nodes_dir}. "
|
| 202 |
+
"Was comfyui-animoflow cloned at Docker build time?"
|
| 203 |
+
)
|
| 204 |
+
# The package name has a hyphen so a plain `import` won't work —
|
| 205 |
+
# same dynamic loading pattern as the AnimoFlow IK/Rig nodes.
|
| 206 |
+
pkg_init = nodes_dir / "retargeter" / "__init__.py"
|
| 207 |
+
spec = importlib.util.spec_from_file_location(
|
| 208 |
+
"animoflow_retargeter", str(pkg_init)
|
| 209 |
+
)
|
| 210 |
+
mod = importlib.util.module_from_spec(spec)
|
| 211 |
+
sys.modules["animoflow_retargeter"] = mod
|
| 212 |
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
| 213 |
+
_retargeter = mod.MotionRetargeter()
|
| 214 |
+
log.info("Loaded MotionRetargeter from %s", pkg_init)
|
| 215 |
+
return _retargeter
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _get_post_funcs():
|
| 219 |
+
"""Lazy-load CPU post-processing helpers from comfyui-animoflow nodes.
|
| 220 |
+
|
| 221 |
+
No post-process steps ship today — outlier_fix + foot_skating_fix were
|
| 222 |
+
retired 2026-06-24 (see tests/outlier-post-proc/). The registry is kept
|
| 223 |
+
as the integration point for any future BVH→BVH transformer added to
|
| 224 |
+
comfyui-animoflow/motion_utils/; new entries get wired here and called
|
| 225 |
+
from the ik stage of _run_tail.
|
| 226 |
+
"""
|
| 227 |
+
global _post_funcs
|
| 228 |
+
if _post_funcs is None:
|
| 229 |
+
_post_funcs = {}
|
| 230 |
+
return _post_funcs
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ---------------------------------------------------------------------------
|
| 234 |
+
# GPU-decorated inference — the only call that allocates ZeroGPU
|
| 235 |
+
# ---------------------------------------------------------------------------
|
| 236 |
+
|
| 237 |
+
# ZeroGPU forks a worker for each @spaces.GPU call and serializes the return
|
| 238 |
+
# value back across a pipe. Empirically, exception args are stripped to the
|
| 239 |
+
# class name (sometimes via repr — producing a literal `'RuntimeError'`
|
| 240 |
+
# symptom on the Space). To avoid relying on
|
| 241 |
+
# that serialization, every GPU function catches its own exceptions, JSON-
|
| 242 |
+
# encodes them with a sentinel prefix, and returns bytes. The CPU-side caller
|
| 243 |
+
# detects the sentinel and re-raises a real RuntimeError with the original
|
| 244 |
+
# message + traceback preserved.
|
| 245 |
+
_ERR_SENTINEL = b"\x00\x00ANIMOFLOW_ERR\x00\x00"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _do_inference(
|
| 249 |
+
model: str,
|
| 250 |
+
prompt: str,
|
| 251 |
+
num_frames: int,
|
| 252 |
+
seed: int,
|
| 253 |
+
*,
|
| 254 |
+
guidance_param: float | None = None,
|
| 255 |
+
**extra: Any,
|
| 256 |
+
) -> bytes:
|
| 257 |
+
"""Body of inference. Identical CPU/GPU behaviour for both @GPU wrappers.
|
| 258 |
+
|
| 259 |
+
For models in the orchestrator venv (MDM family), the fork inherits the
|
| 260 |
+
warm singleton via copy-on-write. For outliers (Kimodo, future), the
|
| 261 |
+
function delegates to escape_hatch.invoke() which subprocess-calls into
|
| 262 |
+
the model's own venv — that subprocess inherits the GPU from the fork.
|
| 263 |
+
"""
|
| 264 |
+
from escape_hatch import is_outlier, invoke as escape_invoke
|
| 265 |
+
|
| 266 |
+
if is_outlier(model):
|
| 267 |
+
# Subprocess timeout tracks the active admission window minus a
|
| 268 |
+
# margin, so OUR loud, classified timeout fires before ZeroGPU
|
| 269 |
+
# kills the slot from outside (which would strip the error).
|
| 270 |
+
_window = _kimodo_window_s() if model == "kimodo" else None
|
| 271 |
+
return escape_invoke(
|
| 272 |
+
model,
|
| 273 |
+
prompt=prompt,
|
| 274 |
+
num_frames=num_frames,
|
| 275 |
+
seed=seed,
|
| 276 |
+
guidance_param=guidance_param,
|
| 277 |
+
**({"timeout": max(30, _window - 5)} if _window else {}),
|
| 278 |
+
**extra,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
from animoflow_models import generate as model_generate
|
| 282 |
+
|
| 283 |
+
npz_bytes, _meta = model_generate(
|
| 284 |
+
model,
|
| 285 |
+
prompt=prompt,
|
| 286 |
+
num_frames=num_frames,
|
| 287 |
+
seed=seed,
|
| 288 |
+
guidance_param=guidance_param,
|
| 289 |
+
**extra,
|
| 290 |
+
)
|
| 291 |
+
return npz_bytes
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _safe_do_inference(
|
| 295 |
+
model: str,
|
| 296 |
+
prompt: str,
|
| 297 |
+
num_frames: int,
|
| 298 |
+
seed: int,
|
| 299 |
+
*,
|
| 300 |
+
guidance_param: float | None = None,
|
| 301 |
+
**extra: Any,
|
| 302 |
+
) -> bytes:
|
| 303 |
+
"""_do_inference wrapped to survive ZeroGPU's cross-worker exception strip.
|
| 304 |
+
|
| 305 |
+
Returns either the raw NPZ bytes, OR `_ERR_SENTINEL + json(...)` carrying
|
| 306 |
+
the error class + message + truncated traceback. Callers MUST check the
|
| 307 |
+
sentinel and re-raise.
|
| 308 |
+
"""
|
| 309 |
+
try:
|
| 310 |
+
return _do_inference(
|
| 311 |
+
model, prompt, num_frames, seed,
|
| 312 |
+
guidance_param=guidance_param, **extra,
|
| 313 |
+
)
|
| 314 |
+
except BaseException as e:
|
| 315 |
+
payload = json.dumps({
|
| 316 |
+
"error_class": type(e).__name__,
|
| 317 |
+
"error_module": type(e).__module__,
|
| 318 |
+
"message": str(e) or repr(e),
|
| 319 |
+
"traceback": traceback.format_exc()[-4000:],
|
| 320 |
+
}).encode()
|
| 321 |
+
return _ERR_SENTINEL + payload
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _unwrap_gpu_result(npz_bytes: bytes) -> bytes:
|
| 325 |
+
"""Inverse of _safe_do_inference: raise if the sentinel is present,
|
| 326 |
+
otherwise pass the NPZ bytes through.
|
| 327 |
+
|
| 328 |
+
Reraises as a RuntimeError carrying the original message — preserved
|
| 329 |
+
across the ZeroGPU process boundary via JSON instead of pickle.
|
| 330 |
+
"""
|
| 331 |
+
if not npz_bytes.startswith(_ERR_SENTINEL):
|
| 332 |
+
return npz_bytes
|
| 333 |
+
try:
|
| 334 |
+
err = json.loads(npz_bytes[len(_ERR_SENTINEL):].decode())
|
| 335 |
+
except Exception: # noqa: BLE001
|
| 336 |
+
raise RuntimeError("GPU worker failed (error payload unparseable)")
|
| 337 |
+
msg = err.get("message") or f"{err.get('error_class', 'Unknown')}: <no message>"
|
| 338 |
+
tb = err.get("traceback") or ""
|
| 339 |
+
if tb:
|
| 340 |
+
log.error("GPU worker raised %s:\n%s", err.get("error_class"), tb)
|
| 341 |
+
raise RuntimeError(msg)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# GPU-decorated wrappers around the same body. MDM/MoMask take the cheap
|
| 345 |
+
# 30-second slot. Kimodo's admission window is DYNAMIC (spaces supports
|
| 346 |
+
# duration=callable, resolved per call in this process): 300 s cold —
|
| 347 |
+
# the first call after boot pays LLaMA-encoder-sidecar startup (~150 s
|
| 348 |
+
# measured 2026-07-07) — then ~70 s warm (holds 25-42 s measured; wire =
|
| 349 |
+
# decorator × duration_factor 1.5 → 105 s, fitting even a fresh anonymous
|
| 350 |
+
# ZeroGPU bucket of 120 s). Behind KIMODO_WINDOW=dynamic; the default
|
| 351 |
+
# (static) keeps the flat 300 s. Dispatch happens at the orchestrator
|
| 352 |
+
# (CPU) layer in run() — MDM/MoMask quota burn is unaffected.
|
| 353 |
+
|
| 354 |
+
_KIMODO_COLD_WINDOW_S = 300
|
| 355 |
+
_kimodo_warmed = False # set after the first successful inference; reset on
|
| 356 |
+
# a warm-window timeout so the next call self-heals
|
| 357 |
+
# with the cold window (loud, never silent)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _kimodo_window_s() -> int:
|
| 361 |
+
if os.environ.get("KIMODO_WINDOW", "static").strip().lower() != "dynamic":
|
| 362 |
+
return _KIMODO_COLD_WINDOW_S
|
| 363 |
+
if not _kimodo_warmed:
|
| 364 |
+
return _KIMODO_COLD_WINDOW_S
|
| 365 |
+
try:
|
| 366 |
+
return int(os.environ.get("KIMODO_WARM_WINDOW_S", "70") or 70)
|
| 367 |
+
except ValueError:
|
| 368 |
+
return _KIMODO_COLD_WINDOW_S
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _kimodo_prewarm_loop() -> None:
|
| 372 |
+
"""Boot pre-warm (KIMODO_PREWARM=1): one token-less Kimodo inference as
|
| 373 |
+
soon as the venv sentinel appears, so the first USER call gets the warm
|
| 374 |
+
window instead of paying the ~150 s sidecar startup. Runs outside any
|
| 375 |
+
gradio request → schedules token-less on the Space's shared pool by
|
| 376 |
+
construction. Failure is LOUD and leaves the cold window active — the
|
| 377 |
+
system stays correct, just slower (no silent fallback)."""
|
| 378 |
+
import threading # noqa: F401 — imported here to keep module deps flat
|
| 379 |
+
|
| 380 |
+
try:
|
| 381 |
+
from bootstrap import _kimodo_ready_sentinel
|
| 382 |
+
except Exception:
|
| 383 |
+
log.exception("[kimodo-prewarm] cannot resolve bootstrap sentinel — "
|
| 384 |
+
"pre-warm disabled, cold window stays active")
|
| 385 |
+
return
|
| 386 |
+
deadline = time.time() + 30 * 60
|
| 387 |
+
while time.time() < deadline:
|
| 388 |
+
if _kimodo_ready_sentinel().is_file():
|
| 389 |
+
break
|
| 390 |
+
time.sleep(10)
|
| 391 |
+
else:
|
| 392 |
+
log.error("[kimodo-prewarm] venv sentinel never appeared in 30 min — "
|
| 393 |
+
"pre-warm skipped, cold window stays active")
|
| 394 |
+
return
|
| 395 |
+
log.info("[kimodo-prewarm] venv ready — running the cold call now "
|
| 396 |
+
"(shared pool, ~150 s)")
|
| 397 |
+
try:
|
| 398 |
+
raw = _run_inference_gpu_kimodo(
|
| 399 |
+
"kimodo", "a person walks forward", 100, 0)
|
| 400 |
+
_unwrap_gpu_result(raw)
|
| 401 |
+
global _kimodo_warmed
|
| 402 |
+
_kimodo_warmed = True
|
| 403 |
+
log.info("[kimodo-prewarm] done — warm window (%ss) active",
|
| 404 |
+
_kimodo_window_s())
|
| 405 |
+
except Exception:
|
| 406 |
+
log.exception("[kimodo-prewarm] FAILED — cold window stays active")
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def start_kimodo_prewarm_if_enabled() -> None:
|
| 410 |
+
"""Called at module import (Space boot). No-op unless KIMODO_PREWARM=1."""
|
| 411 |
+
if os.environ.get("KIMODO_PREWARM", "").strip() != "1":
|
| 412 |
+
return
|
| 413 |
+
import threading
|
| 414 |
+
threading.Thread(target=_kimodo_prewarm_loop, daemon=True,
|
| 415 |
+
name="kimodo-prewarm").start()
|
| 416 |
+
log.info("[kimodo-prewarm] armed (waiting for venv sentinel)")
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
@GPU(duration=30)
|
| 420 |
+
def _run_inference_gpu_fast(
|
| 421 |
+
model: str,
|
| 422 |
+
prompt: str,
|
| 423 |
+
num_frames: int,
|
| 424 |
+
seed: int,
|
| 425 |
+
*,
|
| 426 |
+
guidance_param: float | None = None,
|
| 427 |
+
**extra: Any,
|
| 428 |
+
) -> bytes:
|
| 429 |
+
return _safe_do_inference(
|
| 430 |
+
model, prompt, num_frames, seed,
|
| 431 |
+
guidance_param=guidance_param, **extra,
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@GPU(duration=lambda *a, **k: _kimodo_window_s())
|
| 436 |
+
def _run_inference_gpu_kimodo(
|
| 437 |
+
model: str,
|
| 438 |
+
prompt: str,
|
| 439 |
+
num_frames: int,
|
| 440 |
+
seed: int,
|
| 441 |
+
*,
|
| 442 |
+
guidance_param: float | None = None,
|
| 443 |
+
**extra: Any,
|
| 444 |
+
) -> bytes:
|
| 445 |
+
return _safe_do_inference(
|
| 446 |
+
model, prompt, num_frames, seed,
|
| 447 |
+
guidance_param=guidance_param, **extra,
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# Timeline GPU wrapper — priorMDM double-take. Pass 1 (N segments × ~50 steps)
|
| 452 |
+
# + Pass 2 (handshake re-diffuse, ~5 steps) is ~30-50 s warm on H200 for a
|
| 453 |
+
# 4-segment ~6 s clip. 60 s budget is comfortable; runs on a separate
|
| 454 |
+
# decorator from the 30 s _run_inference_gpu_fast slot because ZeroGPU
|
| 455 |
+
# duration is a static decorator arg.
|
| 456 |
+
@GPU(duration=60)
|
| 457 |
+
def _run_timeline_gpu(
|
| 458 |
+
segments: list[dict],
|
| 459 |
+
seed: int,
|
| 460 |
+
*,
|
| 461 |
+
guidance_param: float = 2.5,
|
| 462 |
+
handshake_size: int = 10,
|
| 463 |
+
blend_len: int = 10,
|
| 464 |
+
) -> bytes:
|
| 465 |
+
"""ZeroGPU-wrapped priorMDM.generate_timeline.
|
| 466 |
+
|
| 467 |
+
Same sentinel-prefix pattern as `_safe_do_inference` — ZeroGPU strips
|
| 468 |
+
exception args across the worker boundary, so we round-trip errors as
|
| 469 |
+
JSON and let `_unwrap_gpu_result` re-raise them on the orchestrator side.
|
| 470 |
+
"""
|
| 471 |
+
try:
|
| 472 |
+
from animoflow_models.registry import _load
|
| 473 |
+
|
| 474 |
+
inst = _load("priormdm")
|
| 475 |
+
npz_bytes, _meta = inst.generate_timeline(
|
| 476 |
+
segments=segments,
|
| 477 |
+
seed=seed,
|
| 478 |
+
guidance_param=guidance_param,
|
| 479 |
+
handshake_size=handshake_size,
|
| 480 |
+
blend_len=blend_len,
|
| 481 |
+
)
|
| 482 |
+
return npz_bytes
|
| 483 |
+
except BaseException as e: # noqa: BLE001 — round-trip across the worker
|
| 484 |
+
payload = json.dumps({
|
| 485 |
+
"error_class": type(e).__name__,
|
| 486 |
+
"error_module": type(e).__module__,
|
| 487 |
+
"message": str(e) or repr(e),
|
| 488 |
+
"traceback": traceback.format_exc()[-4000:],
|
| 489 |
+
}).encode()
|
| 490 |
+
return _ERR_SENTINEL + payload
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
# ---------------------------------------------------------------------------
|
| 494 |
+
# Plan executor — walks the SAME plan the API server compiles for ComfyUI
|
| 495 |
+
# ---------------------------------------------------------------------------
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def _make_stage_cb(on_progress, on_stage):
|
| 499 |
+
def _stage(label: str, progress: float) -> None:
|
| 500 |
+
if on_stage:
|
| 501 |
+
try:
|
| 502 |
+
on_stage(label)
|
| 503 |
+
except Exception: # noqa: BLE001
|
| 504 |
+
log.exception("on_stage callback raised")
|
| 505 |
+
if on_progress:
|
| 506 |
+
try:
|
| 507 |
+
on_progress(progress)
|
| 508 |
+
except Exception: # noqa: BLE001
|
| 509 |
+
log.exception("on_progress callback raised")
|
| 510 |
+
return _stage
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def _run_tail(
|
| 514 |
+
*,
|
| 515 |
+
job_id: str,
|
| 516 |
+
npz_bytes: bytes,
|
| 517 |
+
plan_specs: list,
|
| 518 |
+
output_fps: int,
|
| 519 |
+
compress_output: bool,
|
| 520 |
+
_stage: Callable[[str, float], None],
|
| 521 |
+
_timings: dict[str, float],
|
| 522 |
+
t0: float,
|
| 523 |
+
) -> str:
|
| 524 |
+
"""Execute the post-generate stages of a plan (resample → IK → rig →
|
| 525 |
+
glb_export) against the HF-side implementations. Returns the FBX
|
| 526 |
+
filename written to OUTPUT_DIR."""
|
| 527 |
+
stages = _stages()
|
| 528 |
+
fbx_path: Path | None = None
|
| 529 |
+
bvh_bytes: bytes | None = None
|
| 530 |
+
|
| 531 |
+
# Kimodo emits a rig-ready BVH (SMPL-free rotation path), so its plan has no
|
| 532 |
+
# `ik` stage — the generated bytes ARE the BVH. Seed bvh_bytes directly so the
|
| 533 |
+
# rig stage consumes it; resample/ik simply don't appear in the plan.
|
| 534 |
+
if not any(spec.kind == "ik" for spec in plan_specs):
|
| 535 |
+
bvh_bytes = npz_bytes
|
| 536 |
+
|
| 537 |
+
for spec in plan_specs:
|
| 538 |
+
if spec.kind in ("generate", "generate_timeline"):
|
| 539 |
+
continue # handled by the caller (GPU dispatch differs)
|
| 540 |
+
|
| 541 |
+
if spec.kind == "resample":
|
| 542 |
+
# Always run — even at equal rates it stamps the
|
| 543 |
+
# authoritative fps key downstream stages rely on.
|
| 544 |
+
_t = time.perf_counter()
|
| 545 |
+
in_fps = spec.params["input_fps"]
|
| 546 |
+
npz_bytes = stages.resample.resample_npz(
|
| 547 |
+
npz_bytes, in_fps, spec.params["output_fps"])
|
| 548 |
+
_timings["resample"] = time.perf_counter() - _t
|
| 549 |
+
log.info(
|
| 550 |
+
"[%s] resampled %d→%dfps (%d KB NPZ)",
|
| 551 |
+
job_id, in_fps, spec.params["output_fps"],
|
| 552 |
+
len(npz_bytes) // 1024,
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
elif spec.kind == "ik":
|
| 556 |
+
_stage("Inverse Kinematics…", 0.55)
|
| 557 |
+
_t = time.perf_counter()
|
| 558 |
+
retargeter = _get_retargeter()
|
| 559 |
+
# Pass the authoritative post-resample rate — the BVH Frame
|
| 560 |
+
# Time drives retarget_keemap's scene FPS and thus the final
|
| 561 |
+
# GLB timing.
|
| 562 |
+
bvh_bytes, ik_meta = retargeter.npz_to_bvh(npz_bytes, fps=output_fps)
|
| 563 |
+
_timings["ik"] = time.perf_counter() - _t
|
| 564 |
+
log.info(
|
| 565 |
+
"[%s] IK done: %d frames, %d joints",
|
| 566 |
+
job_id, ik_meta["num_frames"], ik_meta["num_joints"],
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
# Post-process slot intentionally empty — _get_post_funcs().
|
| 570 |
+
|
| 571 |
+
# Optional debug dump (off by default): persist per-stage
|
| 572 |
+
# intermediates. When on, /v1/files/{job_id}.{npz,bvh} become
|
| 573 |
+
# fetchable alongside .fbx (each artifact needs a single-dot
|
| 574 |
+
# extension — /v1/files splits on the last dot).
|
| 575 |
+
if os.environ.get("ANIMOFLOW_DEBUG_DUMP", "0").lower() in ("1", "true", "yes"):
|
| 576 |
+
try:
|
| 577 |
+
(_OUTPUT_DIR / f"{job_id}.npz").write_bytes(npz_bytes)
|
| 578 |
+
(_OUTPUT_DIR / f"{job_id}.bvh").write_bytes(bvh_bytes)
|
| 579 |
+
log.info(
|
| 580 |
+
"[%s] DEBUG_DUMP wrote .npz (%dKB) .bvh (%dKB, raw post-IK)",
|
| 581 |
+
job_id, len(npz_bytes)//1024, len(bvh_bytes)//1024,
|
| 582 |
+
)
|
| 583 |
+
except Exception: # noqa: BLE001
|
| 584 |
+
log.exception("DEBUG_DUMP failed (non-fatal)")
|
| 585 |
+
|
| 586 |
+
elif spec.kind == "rig":
|
| 587 |
+
if bvh_bytes is None:
|
| 588 |
+
raise RuntimeError("rig stage reached with no BVH (missing ik stage?)")
|
| 589 |
+
_stage("Rigging…", 0.80)
|
| 590 |
+
_t = time.perf_counter()
|
| 591 |
+
retargeter = _get_retargeter()
|
| 592 |
+
character = spec.params["character"]
|
| 593 |
+
chars_dir = Path(
|
| 594 |
+
os.environ.get("CHARACTERS_DIR", str(_COMFY_ROOT / "characters"))
|
| 595 |
+
)
|
| 596 |
+
fbx_template = chars_dir / f"{character}.fbx"
|
| 597 |
+
if not fbx_template.exists():
|
| 598 |
+
raise RuntimeError(
|
| 599 |
+
f"Character template not found: {fbx_template}. "
|
| 600 |
+
f"Available: {[p.stem for p in chars_dir.glob('*.fbx')]}"
|
| 601 |
+
)
|
| 602 |
+
fbx_bytes, _rig_meta = retargeter.bvh_to_fbx(
|
| 603 |
+
bvh_bytes, fbx_template=str(fbx_template)
|
| 604 |
+
)
|
| 605 |
+
_timings["retarget"] = time.perf_counter() - _t
|
| 606 |
+
log.info("[%s] retarget done: %d KB FBX", job_id, len(fbx_bytes) // 1024)
|
| 607 |
+
|
| 608 |
+
fbx_path = _OUTPUT_DIR / f"{job_id}.fbx"
|
| 609 |
+
fbx_path.write_bytes(fbx_bytes)
|
| 610 |
+
|
| 611 |
+
elif spec.kind == "glb_export":
|
| 612 |
+
if fbx_path is None:
|
| 613 |
+
raise RuntimeError("glb_export reached with no FBX written")
|
| 614 |
+
_stage("Post processing…", 0.95)
|
| 615 |
+
p = spec.params
|
| 616 |
+
character = p["character"]
|
| 617 |
+
traj = p.get("traj_restore") or {}
|
| 618 |
+
_t = time.perf_counter()
|
| 619 |
+
glb_path: Path | None = fbx_path.with_suffix(".glb")
|
| 620 |
+
snap_info: dict | None = None
|
| 621 |
+
snap_elapsed = 0.0
|
| 622 |
+
try:
|
| 623 |
+
options = stages.glb_export.GLBExportOptions(
|
| 624 |
+
fbx_path=str(fbx_path),
|
| 625 |
+
glb_path=str(glb_path),
|
| 626 |
+
character=character,
|
| 627 |
+
snap_to_ground=p["snap_to_ground"],
|
| 628 |
+
characters_dir=os.environ.get(
|
| 629 |
+
"CHARACTERS_DIR", str(_COMFY_ROOT / "characters")),
|
| 630 |
+
cache_dir=str(_OUTPUT_DIR),
|
| 631 |
+
comfy_root=str(_COMFY_ROOT),
|
| 632 |
+
traj_theta=float(traj.get("theta_rad", 0.0)),
|
| 633 |
+
traj_tx=float(traj.get("tx", 0.0)),
|
| 634 |
+
traj_tz=float(traj.get("tz", 0.0)),
|
| 635 |
+
keyframe_builder=p["keyframe_builder"],
|
| 636 |
+
keyframe_builder_error_degrees=p["keyframe_builder_error_degrees"],
|
| 637 |
+
)
|
| 638 |
+
result = stages.glb_export.run_glb_export(options, timeout=120)
|
| 639 |
+
snap_info = result["snap_info"]
|
| 640 |
+
snap_elapsed = result["snap_elapsed_s"]
|
| 641 |
+
except Exception: # noqa: BLE001
|
| 642 |
+
# GLB export is best-effort on the Space — the FBX is
|
| 643 |
+
# already written and downloadable. (The local ComfyUI
|
| 644 |
+
# node raises instead: there the GLB IS the terminal
|
| 645 |
+
# output.)
|
| 646 |
+
log.exception("GLB export failed (FBX still written)")
|
| 647 |
+
glb_path = None
|
| 648 |
+
_timings["glb"] = time.perf_counter() - _t
|
| 649 |
+
|
| 650 |
+
# Sidecar JSON the API layer reads and surfaces as
|
| 651 |
+
# JobResponse.snap_info.
|
| 652 |
+
if snap_info is None and not p["snap_to_ground"]:
|
| 653 |
+
snap_info = {"applied": False,
|
| 654 |
+
"reason": "request snap_to_ground=false"}
|
| 655 |
+
if snap_info is not None:
|
| 656 |
+
try:
|
| 657 |
+
(_OUTPUT_DIR / f"{job_id}.snap.json").write_text(
|
| 658 |
+
json.dumps(snap_info))
|
| 659 |
+
except OSError:
|
| 660 |
+
log.exception("Failed to write snap_info sidecar")
|
| 661 |
+
# Snap wall time runs inside the Blender subprocess; surface
|
| 662 |
+
# it separately and keep the timing sum honest.
|
| 663 |
+
if snap_elapsed > 0.0:
|
| 664 |
+
_timings["snap"] = snap_elapsed
|
| 665 |
+
_timings["glb"] = max(_timings["glb"] - snap_elapsed, 0.0)
|
| 666 |
+
|
| 667 |
+
# Embedded-texture downsize BEFORE gltfpack (alignment rules
|
| 668 |
+
# — see animoflow_stages/glb_post.py). No-op on textureless
|
| 669 |
+
# characters. Env-gated, default ON here — the Space's file
|
| 670 |
+
# serving is throttled ~100 KB/s.
|
| 671 |
+
if glb_path is not None and os.environ.get(
|
| 672 |
+
"ENABLE_TEXTURE_DOWNSIZE", "true"
|
| 673 |
+
).lower() == "true":
|
| 674 |
+
_t = time.perf_counter()
|
| 675 |
+
stages.glb_post.downsize_glb_textures(glb_path)
|
| 676 |
+
_timings["glb_texresize"] = time.perf_counter() - _t
|
| 677 |
+
|
| 678 |
+
# gltfpack -cc. Two opt-outs: ENABLE_GLB_COMPRESSION env kill
|
| 679 |
+
# switch, and the per-request compress_output flag (the
|
| 680 |
+
# Blender addon opts out — Blender 5 rejects
|
| 681 |
+
# EXT_meshopt_compression). Failures raise — no silent
|
| 682 |
+
# fallback.
|
| 683 |
+
if (
|
| 684 |
+
glb_path is not None
|
| 685 |
+
and compress_output
|
| 686 |
+
and os.environ.get("ENABLE_GLB_COMPRESSION", "true").lower() == "true"
|
| 687 |
+
):
|
| 688 |
+
_t = time.perf_counter()
|
| 689 |
+
stages.glb_post.compress_glb(glb_path)
|
| 690 |
+
_timings["glb_compress"] = time.perf_counter() - _t
|
| 691 |
+
|
| 692 |
+
else:
|
| 693 |
+
raise ValueError(f"Unknown stage kind {spec.kind!r}")
|
| 694 |
+
|
| 695 |
+
if fbx_path is None:
|
| 696 |
+
raise RuntimeError("plan finished without producing an FBX")
|
| 697 |
+
|
| 698 |
+
_timings["total"] = time.perf_counter() - t0
|
| 699 |
+
_timing_parts = " ".join(f"{k}={v:.2f}s" for k, v in _timings.items())
|
| 700 |
+
log.info("[STAGE_TIMINGS] job=%s %s", job_id, _timing_parts)
|
| 701 |
+
log.info(
|
| 702 |
+
"[%s] HF pipeline complete in %.1fs total → %s",
|
| 703 |
+
job_id, _timings["total"], fbx_path.name,
|
| 704 |
+
)
|
| 705 |
+
return fbx_path.name
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
# ---------------------------------------------------------------------------
|
| 709 |
+
# Public API — drop-in replacement for animoflow-api/api/pipeline.py
|
| 710 |
+
# ---------------------------------------------------------------------------
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def run(
|
| 714 |
+
job_id: str,
|
| 715 |
+
prompt: str,
|
| 716 |
+
num_frames: int,
|
| 717 |
+
seed: int,
|
| 718 |
+
character: str = "Y_bot",
|
| 719 |
+
model: str = "mdm",
|
| 720 |
+
on_progress: Callable[[float], None] | None = None,
|
| 721 |
+
on_stage: Callable[[str], None] | None = None,
|
| 722 |
+
preprocess_stages: list[dict] | None = None,
|
| 723 |
+
keyframe_builder: bool = False,
|
| 724 |
+
keyframe_builder_error_degrees: float = 3.0,
|
| 725 |
+
snap_to_ground: bool = True,
|
| 726 |
+
output_fps: int = 30,
|
| 727 |
+
cfg: float = 2.5,
|
| 728 |
+
curve_2d: list[list[float]] | None = None,
|
| 729 |
+
traj_restore: dict | None = None,
|
| 730 |
+
accel_frac: float = 0.25,
|
| 731 |
+
decel_frac: float = 0.25,
|
| 732 |
+
waypoints: list[dict] | None = None,
|
| 733 |
+
compress_output: bool = True,
|
| 734 |
+
) -> str:
|
| 735 |
+
"""HF replacement for animoflow-api/api/pipeline.py:run.
|
| 736 |
+
|
| 737 |
+
Returns the output filename (e.g. 'abc.fbx'). Same semantics: writes
|
| 738 |
+
to OUTPUT_DIR, raises on failure.
|
| 739 |
+
|
| 740 |
+
Conditioning args:
|
| 741 |
+
- accel_frac, decel_frac: priorMDM-only velocity-profile shaping.
|
| 742 |
+
- curve_2d: priorMDM (trajectory) or Kimodo (trajectory task — dense XZ
|
| 743 |
+
path; frame_indices are evenly distributed across num_frames).
|
| 744 |
+
- waypoints: Kimodo-only (waypoint task — sparse list of {x, z, t}
|
| 745 |
+
where t is a frame index in [0, num_frames). Wins over curve_2d
|
| 746 |
+
when both are set.
|
| 747 |
+
"""
|
| 748 |
+
t0 = time.perf_counter()
|
| 749 |
+
log.info(
|
| 750 |
+
"[%s] HF pipeline run: model=%s prompt=%r num_frames=%d seed=%d character=%s",
|
| 751 |
+
job_id, model, prompt[:60], num_frames, seed, character,
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
_timings: dict[str, float] = {}
|
| 755 |
+
_stage = _make_stage_cb(on_progress, on_stage)
|
| 756 |
+
# Fire the first stage label before touching the stage library so
|
| 757 |
+
# callers see progress even when the checkout is missing/broken.
|
| 758 |
+
_stage("Generating…", 0.05)
|
| 759 |
+
|
| 760 |
+
stages = _stages()
|
| 761 |
+
plan_specs = stages.plan.build_plan(
|
| 762 |
+
model, character,
|
| 763 |
+
prompt=prompt,
|
| 764 |
+
num_frames=num_frames,
|
| 765 |
+
seed=seed,
|
| 766 |
+
output_fps=output_fps,
|
| 767 |
+
snap_to_ground=snap_to_ground,
|
| 768 |
+
keyframe_builder=keyframe_builder,
|
| 769 |
+
keyframe_builder_error_degrees=keyframe_builder_error_degrees,
|
| 770 |
+
traj_restore=traj_restore,
|
| 771 |
+
compress_output=compress_output,
|
| 772 |
+
curve_2d=curve_2d,
|
| 773 |
+
waypoints=waypoints,
|
| 774 |
+
accel_frac=accel_frac,
|
| 775 |
+
decel_frac=decel_frac,
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
# ── Stage 1: GPU inference ───────────────────────────────────────────
|
| 779 |
+
_t = time.perf_counter()
|
| 780 |
+
# Per-model conditioning kwargs come from the SHARED mapping — the
|
| 781 |
+
# same one the ComfyUI compiler uses for node inputs.
|
| 782 |
+
extra = stages.genparams.build_hf_gen_extra(
|
| 783 |
+
model,
|
| 784 |
+
num_frames=num_frames,
|
| 785 |
+
cfg=cfg,
|
| 786 |
+
curve_2d=curve_2d,
|
| 787 |
+
waypoints=waypoints,
|
| 788 |
+
accel_frac=accel_frac,
|
| 789 |
+
decel_frac=decel_frac,
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
# Dispatch to the appropriately-budgeted @spaces.GPU wrapper. Kimodo
|
| 793 |
+
# needs ~300 s for cold-start (LLaMA encoder warmup + model load);
|
| 794 |
+
# MDM/MoMask stay on the cheap 30 s slot so their daily ZeroGPU quota
|
| 795 |
+
# is unaffected.
|
| 796 |
+
gpu_fn = (
|
| 797 |
+
_run_inference_gpu_kimodo if model == "kimodo" else _run_inference_gpu_fast
|
| 798 |
+
)
|
| 799 |
+
# Guidance scale from the request (cfg), already clamped to the
|
| 800 |
+
# model's range by the API server. Was hardcoded None (each model's
|
| 801 |
+
# internal default) — passing it explicitly is what makes the /v1
|
| 802 |
+
# `cfg` field actually take effect for simple-mode tasks.
|
| 803 |
+
global _kimodo_warmed
|
| 804 |
+
try:
|
| 805 |
+
npz_bytes = gpu_fn(
|
| 806 |
+
model, prompt, num_frames, seed, guidance_param=cfg, **extra,
|
| 807 |
+
)
|
| 808 |
+
# Decode the sentinel-prefixed error payload if the GPU worker raised.
|
| 809 |
+
npz_bytes = _unwrap_gpu_result(npz_bytes)
|
| 810 |
+
except Exception as exc:
|
| 811 |
+
if model == "kimodo" and _kimodo_warmed and (
|
| 812 |
+
"timeout" in str(exc).lower() or "timed out" in str(exc).lower()
|
| 813 |
+
or "aborted" in str(exc).lower()):
|
| 814 |
+
# A warm-windowed run overran or was killed: drop back to the
|
| 815 |
+
# cold window so the NEXT call self-heals. Loud by design.
|
| 816 |
+
_kimodo_warmed = False
|
| 817 |
+
log.error("[kimodo-window] warm-window run failed (%s) — warm "
|
| 818 |
+
"flag RESET, next call uses the %ss cold window",
|
| 819 |
+
type(exc).__name__, _KIMODO_COLD_WINDOW_S)
|
| 820 |
+
raise
|
| 821 |
+
if model == "kimodo" and not _kimodo_warmed:
|
| 822 |
+
_kimodo_warmed = True
|
| 823 |
+
log.info("[kimodo-window] first successful inference — warm window "
|
| 824 |
+
"(%ss) active for subsequent calls", _kimodo_window_s())
|
| 825 |
+
_timings["gpu"] = time.perf_counter() - _t
|
| 826 |
+
log.info(
|
| 827 |
+
"[%s] inference done in %.1fs (%d KB NPZ)",
|
| 828 |
+
job_id, _timings["gpu"], len(npz_bytes) // 1024,
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
return _run_tail(
|
| 832 |
+
job_id=job_id,
|
| 833 |
+
npz_bytes=npz_bytes,
|
| 834 |
+
plan_specs=plan_specs,
|
| 835 |
+
output_fps=output_fps,
|
| 836 |
+
compress_output=compress_output,
|
| 837 |
+
_stage=_stage,
|
| 838 |
+
_timings=_timings,
|
| 839 |
+
t0=t0,
|
| 840 |
+
)
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def run_timeline(
|
| 844 |
+
job_id: str,
|
| 845 |
+
segments: list[dict],
|
| 846 |
+
seed: int,
|
| 847 |
+
character: str = "Y_bot",
|
| 848 |
+
cfg: float = 2.5,
|
| 849 |
+
handshake_size: int = 10,
|
| 850 |
+
blend_len: int = 10,
|
| 851 |
+
on_progress: Callable[[float], None] | None = None,
|
| 852 |
+
on_stage: Callable[[str], None] | None = None,
|
| 853 |
+
keyframe_builder: bool = False,
|
| 854 |
+
keyframe_builder_error_degrees: float = 3.0,
|
| 855 |
+
snap_to_ground: bool = True,
|
| 856 |
+
output_fps: int = 30,
|
| 857 |
+
compress_output: bool = True,
|
| 858 |
+
) -> str:
|
| 859 |
+
"""HF replacement for animoflow-api/api/pipeline.py:run_timeline.
|
| 860 |
+
|
| 861 |
+
Drives priorMDM's double-take long-motion path. Each segment is
|
| 862 |
+
`{"prompt": str, "num_frames": int}` — frame counts are caller-resolved
|
| 863 |
+
against priorMDM's 20 fps native rate (api/main.py converts segment
|
| 864 |
+
duration → num_frames at _PRIORMDM_FPS=20).
|
| 865 |
+
"""
|
| 866 |
+
t0 = time.perf_counter()
|
| 867 |
+
log.info(
|
| 868 |
+
"[%s] HF pipeline run_timeline: %d segments seed=%d character=%s",
|
| 869 |
+
job_id, len(segments), seed, character,
|
| 870 |
+
)
|
| 871 |
+
|
| 872 |
+
_timings: dict[str, float] = {}
|
| 873 |
+
_stage = _make_stage_cb(on_progress, on_stage)
|
| 874 |
+
_stage("Generating long motion…", 0.05)
|
| 875 |
+
|
| 876 |
+
stages = _stages()
|
| 877 |
+
plan_specs = stages.plan.build_timeline_plan(
|
| 878 |
+
segments, character,
|
| 879 |
+
seed=seed,
|
| 880 |
+
cfg=cfg,
|
| 881 |
+
handshake_size=handshake_size,
|
| 882 |
+
blend_len=blend_len,
|
| 883 |
+
output_fps=output_fps,
|
| 884 |
+
snap_to_ground=snap_to_ground,
|
| 885 |
+
keyframe_builder=keyframe_builder,
|
| 886 |
+
keyframe_builder_error_degrees=keyframe_builder_error_degrees,
|
| 887 |
+
compress_output=compress_output,
|
| 888 |
+
)
|
| 889 |
+
|
| 890 |
+
# ── Stage 1: GPU inference (priorMDM double-take) ────────────────────
|
| 891 |
+
_t = time.perf_counter()
|
| 892 |
+
npz_bytes = _run_timeline_gpu(
|
| 893 |
+
segments=segments,
|
| 894 |
+
seed=seed,
|
| 895 |
+
guidance_param=cfg,
|
| 896 |
+
handshake_size=handshake_size,
|
| 897 |
+
blend_len=blend_len,
|
| 898 |
+
)
|
| 899 |
+
npz_bytes = _unwrap_gpu_result(npz_bytes)
|
| 900 |
+
_timings["gpu"] = time.perf_counter() - _t
|
| 901 |
+
log.info(
|
| 902 |
+
"[%s] timeline inference done in %.1fs (%d KB NPZ)",
|
| 903 |
+
job_id, _timings["gpu"], len(npz_bytes) // 1024,
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
return _run_tail(
|
| 907 |
+
job_id=job_id,
|
| 908 |
+
npz_bytes=npz_bytes,
|
| 909 |
+
plan_specs=plan_specs,
|
| 910 |
+
output_fps=output_fps,
|
| 911 |
+
compress_output=compress_output,
|
| 912 |
+
_stage=_stage,
|
| 913 |
+
_timings=_timings,
|
| 914 |
+
t0=t0,
|
| 915 |
+
)
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
# Boot pre-warm hook — module import happens once at Space startup (ui.py).
|
| 919 |
+
# No-op unless KIMODO_PREWARM=1.
|
| 920 |
+
start_kimodo_prewarm_if_enabled()
|
requirements.txt
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Orchestrator + MDM family — single venv on HF Space.
|
| 2 |
+
# Outlier models (Kimodo etc.) live in their own venvs (escape_hatch) and are
|
| 3 |
+
# subprocess-called from inside @spaces.GPU.
|
| 4 |
+
|
| 5 |
+
# Core orchestration.
|
| 6 |
+
# NOTE: gradio is intentionally NOT pinned here. HF's Gradio SDK runtime
|
| 7 |
+
# injects its own gradio version (currently 6.13.0); pinning a different
|
| 8 |
+
# version causes a build conflict. For OSS-local dev we accept whatever
|
| 9 |
+
# pip resolves alongside HF's other deps.
|
| 10 |
+
fastapi>=0.115.6
|
| 11 |
+
uvicorn[standard]>=0.32.1
|
| 12 |
+
# gradio — injected by HF Gradio SDK runtime
|
| 13 |
+
httpx>=0.27.0
|
| 14 |
+
python-multipart>=0.0.20
|
| 15 |
+
slowapi>=0.1.9
|
| 16 |
+
websocket-client>=1.8.0
|
| 17 |
+
pydantic>=2.10.4
|
| 18 |
+
|
| 19 |
+
# HF Spaces ZeroGPU runtime — no-op in OSS mode
|
| 20 |
+
# spaces — injected by HF Gradio SDK runtime
|
| 21 |
+
|
| 22 |
+
# huggingface_hub — used at Docker build time to download baked-in model
|
| 23 |
+
# checkpoints from AnimoFlow/animoflow-checkpoints. Also used at runtime
|
| 24 |
+
# by transformers.
|
| 25 |
+
huggingface_hub>=0.27.0
|
| 26 |
+
|
| 27 |
+
# Numerics for IK + retarget pipeline (matches start-local.sh's pinned trio)
|
| 28 |
+
# torch — injected by HF ZeroGPU runtime
|
| 29 |
+
# torchvision — injected by HF ZeroGPU runtime
|
| 30 |
+
numpy>=1.26.4
|
| 31 |
+
scipy>=1.13.1
|
| 32 |
+
matplotlib>=3.9.2
|
| 33 |
+
|
| 34 |
+
# MDM-family deps (CLIP, transformers used by MDM/priorMDM/MoMask)
|
| 35 |
+
transformers>=4.46.3
|
| 36 |
+
ftfy>=6.3.1
|
| 37 |
+
regex>=2024.11.6
|
| 38 |
+
git+https://github.com/openai/CLIP.git
|
| 39 |
+
# Pulled from comfyui-animoflow/containers/mdm/requirements.txt — adding here
|
| 40 |
+
# rather than installing MDM container's reqs separately, because that file
|
| 41 |
+
# pins torch==2.3.0 which downgrades us from 2.4.1 and removes the
|
| 42 |
+
# torch.library.register_fake API the upstream MDM source code uses.
|
| 43 |
+
einops>=0.8.0
|
| 44 |
+
|
| 45 |
+
# Blender retarget pipeline support
|
| 46 |
+
trimesh>=4.5.3
|
| 47 |
+
|
| 48 |
+
# Multilingual prompt rewriter (animoflow-api/api/rewriter.py)
|
| 49 |
+
sentence-transformers>=3.0
|
| 50 |
+
accelerate>=1.0
|
| 51 |
+
|
| 52 |
+
# Tests
|
| 53 |
+
pytest>=8.3.4
|
| 54 |
+
pytest-asyncio>=0.25.0
|
scripts/download_weights.sh
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Best-effort MDM weight download.
|
| 3 |
+
#
|
| 4 |
+
# DELIBERATE: this script never aborts the entrypoint. Failures log a
|
| 5 |
+
# warning + exit 0 so the orchestrator can boot in a "checkpoints missing"
|
| 6 |
+
# state. /v1/health, /v1/tasks, and the Gradio UI all work without
|
| 7 |
+
# weights; only actual inference requests fail (with a clear "checkpoint
|
| 8 |
+
# missing at <path>" error).
|
| 9 |
+
#
|
| 10 |
+
# Manual fallback if the HF mirror URLs below ever break: grab the MDM
|
| 11 |
+
# checkpoint from the upstream Google Drive link in
|
| 12 |
+
# https://github.com/GuyTevet/motion-diffusion-model README and drop it at
|
| 13 |
+
# ${CHECKPOINTS_DIR:-/opt/checkpoints}/humanml_trans_enc_512/model000475000.pt
|
| 14 |
+
# alongside its args.json. Same for priorMDM and MoMask.
|
| 15 |
+
|
| 16 |
+
set -uo pipefail # NOT -e — best-effort
|
| 17 |
+
|
| 18 |
+
CHECKPOINTS_DIR="${CHECKPOINTS_DIR:-/opt/checkpoints}"
|
| 19 |
+
mkdir -p "${CHECKPOINTS_DIR}"
|
| 20 |
+
|
| 21 |
+
_warn() { echo "[weights] WARN: $*" >&2 ; }
|
| 22 |
+
_ok() { echo "[weights] $*" ; }
|
| 23 |
+
|
| 24 |
+
# Fast-path: if the Docker build already baked the canonical 50-step MDM
|
| 25 |
+
# checkpoint via `huggingface_hub.snapshot_download`, there's nothing to
|
| 26 |
+
# fetch at runtime. Skip the legacy mirror dance entirely.
|
| 27 |
+
if [ -f "${CHECKPOINTS_DIR}/humanml_enc_512_50steps/model000750000.pt" ]; then
|
| 28 |
+
_ok "MDM 50-step checkpoint already baked at ${CHECKPOINTS_DIR}/humanml_enc_512_50steps/ (skip)"
|
| 29 |
+
exit 0
|
| 30 |
+
fi
|
| 31 |
+
|
| 32 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 33 |
+
# MDM (humanml_trans_enc_512) — legacy fallback for OSS-local users who
|
| 34 |
+
# build without the hf_token secret. Best-effort; failures don't abort.
|
| 35 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 36 |
+
MDM_DIR="${CHECKPOINTS_DIR}/humanml_trans_enc_512"
|
| 37 |
+
MDM_PT="${MDM_DIR}/model000475000.pt"
|
| 38 |
+
MDM_ARGS="${MDM_DIR}/args.json"
|
| 39 |
+
|
| 40 |
+
if [ -f "${MDM_PT}" ]; then
|
| 41 |
+
_ok "MDM already present at ${MDM_DIR} (skip)"
|
| 42 |
+
else
|
| 43 |
+
_ok "downloading MDM humanml_trans_enc_512 → ${MDM_DIR}"
|
| 44 |
+
mkdir -p "${MDM_DIR}"
|
| 45 |
+
|
| 46 |
+
# Candidate mirror URLs. Add more as we learn what's reachable; first
|
| 47 |
+
# one that works wins. None of these are guaranteed — if all fail,
|
| 48 |
+
# the warning at the bottom tells the user how to drop weights manually.
|
| 49 |
+
_MIRRORS_PT=(
|
| 50 |
+
"https://huggingface.co/guytevet/motion-diffusion-model/resolve/main/save/humanml_trans_enc_512/model000475000.pt"
|
| 51 |
+
"https://huggingface.co/guytevet/MDM/resolve/main/humanml_trans_enc_512/model000475000.pt"
|
| 52 |
+
)
|
| 53 |
+
_MIRRORS_ARGS=(
|
| 54 |
+
"https://huggingface.co/guytevet/motion-diffusion-model/resolve/main/save/humanml_trans_enc_512/args.json"
|
| 55 |
+
"https://huggingface.co/guytevet/MDM/resolve/main/humanml_trans_enc_512/args.json"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
_got_pt=0
|
| 59 |
+
for url in "${_MIRRORS_PT[@]}"; do
|
| 60 |
+
if curl -fsSL --max-time 300 "${url}" -o "${MDM_PT}.partial" 2>/dev/null; then
|
| 61 |
+
mv "${MDM_PT}.partial" "${MDM_PT}"
|
| 62 |
+
_got_pt=1
|
| 63 |
+
_ok "MDM .pt fetched from ${url}"
|
| 64 |
+
break
|
| 65 |
+
fi
|
| 66 |
+
rm -f "${MDM_PT}.partial" 2>/dev/null
|
| 67 |
+
done
|
| 68 |
+
|
| 69 |
+
if [ "${_got_pt}" = "1" ]; then
|
| 70 |
+
for url in "${_MIRRORS_ARGS[@]}"; do
|
| 71 |
+
if curl -fsSL --max-time 60 "${url}" -o "${MDM_ARGS}.partial" 2>/dev/null; then
|
| 72 |
+
mv "${MDM_ARGS}.partial" "${MDM_ARGS}"
|
| 73 |
+
_ok "MDM args.json fetched from ${url}"
|
| 74 |
+
break
|
| 75 |
+
fi
|
| 76 |
+
rm -f "${MDM_ARGS}.partial" 2>/dev/null
|
| 77 |
+
done
|
| 78 |
+
fi
|
| 79 |
+
|
| 80 |
+
if [ ! -f "${MDM_PT}" ]; then
|
| 81 |
+
_warn "all MDM mirror URLs failed."
|
| 82 |
+
_warn "To enable MDM inference, drop the checkpoint manually:"
|
| 83 |
+
_warn " ${MDM_PT}"
|
| 84 |
+
_warn " ${MDM_ARGS}"
|
| 85 |
+
_warn "Source: https://github.com/GuyTevet/motion-diffusion-model README → 'Get data and checkpoints' (Google Drive)."
|
| 86 |
+
fi
|
| 87 |
+
fi
|
| 88 |
+
|
| 89 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 90 |
+
# t2m_mean / t2m_std symlinks
|
| 91 |
+
# These are required for the HumanML3D normalization. They're checked
|
| 92 |
+
# into comfyui-animoflow/containers/mdm/ and the upstream MDM code
|
| 93 |
+
# expects them at $MDM_PATH/dataset/HumanML3D/.
|
| 94 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 95 |
+
MDM_SRC="${MDM_PATH:-/opt/mdm-codes}"
|
| 96 |
+
if [ -d "${MDM_SRC}" ]; then
|
| 97 |
+
mkdir -p "${MDM_SRC}/dataset/HumanML3D" 2>/dev/null
|
| 98 |
+
for npy in t2m_mean t2m_std; do
|
| 99 |
+
SRC="/opt/comfyui-animoflow/containers/mdm/${npy}.npy"
|
| 100 |
+
DST="${MDM_SRC}/dataset/HumanML3D/${npy}.npy"
|
| 101 |
+
if [ -f "${SRC}" ] && [ ! -e "${DST}" ]; then
|
| 102 |
+
ln -sfn "${SRC}" "${DST}" 2>/dev/null && _ok "linked ${npy}.npy"
|
| 103 |
+
fi
|
| 104 |
+
done
|
| 105 |
+
fi
|
| 106 |
+
|
| 107 |
+
_ok "done (best-effort)"
|
| 108 |
+
exit 0
|
scripts/run_inference_kimodo.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
Kimodo escape-hatch runner.
|
| 4 |
+
|
| 5 |
+
Contract (matches escape_hatch/invoke.py:invoke):
|
| 6 |
+
- Stdin: JSON {prompt, num_frames, seed, guidance_param,
|
| 7 |
+
num_denoising_steps?, cfg_text?, cfg_constraint?,
|
| 8 |
+
root2d?: {"frame_indices": [int], "smooth_root_2d": [[x,z]]}}
|
| 9 |
+
num_frames is already in Kimodo's native 30 fps — animoflow-api
|
| 10 |
+
converts duration→frames upstream (api/main.py:623-633).
|
| 11 |
+
root2d is the unified XZ floor-plane constraint that powers both
|
| 12 |
+
"trajectory" (dense frames) and "waypoint" (sparse frames) tasks.
|
| 13 |
+
When omitted, Kimodo runs unconstrained text→motion.
|
| 14 |
+
- Stdout: BVH bytes (22-joint MoMask hierarchy) from the SMPL-free
|
| 15 |
+
rotation-carrying calibrated converter (container fmt="bvh22").
|
| 16 |
+
The rotations carry the pose, so the downstream HF pipeline skips
|
| 17 |
+
IK and consumes the BVH directly (retarget → GLB).
|
| 18 |
+
- Stderr: free-form logs (escape_hatch surfaces last 400 chars on failure).
|
| 19 |
+
- Exit: 0 success, non-zero on any failure.
|
| 20 |
+
|
| 21 |
+
Per the no-silent-fallback policy: every failure raises. No placeholder.
|
| 22 |
+
|
| 23 |
+
Runs from inside the Kimodo venv at $KIMODO_VENV_PYTHON. The orchestrator
|
| 24 |
+
(animoflow-app) spawns this subprocess from inside @spaces.GPU.
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import importlib.util
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import sys
|
| 32 |
+
import traceback
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Stdout is reserved for NPZ bytes — everything else goes to stderr.
|
| 37 |
+
# CRITICAL: kimodo / transformers / py-soma-x all print() to stdout
|
| 38 |
+
# (model load banners, LLM2Vec patch messages, progress bars). Without
|
| 39 |
+
# diversion those bytes would prepend to the NPZ payload and `np.load`
|
| 40 |
+
# would fail with "Failed to interpret file as a pickle". We dup the
|
| 41 |
+
# original stdout fd, redirect Python-level sys.stdout AND fd 1 to
|
| 42 |
+
# stderr for the duration of the library calls, then restore the
|
| 43 |
+
# saved fd only when we're ready to write the NPZ bytes.
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
_STDOUT_BACKUP_FD: int | None = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _divert_stdout_to_stderr() -> None:
|
| 50 |
+
"""Redirect sys.stdout AND raw fd 1 to stderr so library `print()`s
|
| 51 |
+
don't corrupt our binary stdout payload. Saves the original fd so
|
| 52 |
+
we can restore it just before writing the NPZ."""
|
| 53 |
+
global _STDOUT_BACKUP_FD
|
| 54 |
+
if _STDOUT_BACKUP_FD is not None:
|
| 55 |
+
return # idempotent
|
| 56 |
+
sys.stdout.flush()
|
| 57 |
+
_STDOUT_BACKUP_FD = os.dup(1)
|
| 58 |
+
os.dup2(2, 1) # point fd 1 at fd 2 (stderr)
|
| 59 |
+
sys.stdout = sys.stderr
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _restore_stdout_for_npz() -> None:
|
| 63 |
+
"""Restore the original stdout fd for the final NPZ write."""
|
| 64 |
+
global _STDOUT_BACKUP_FD
|
| 65 |
+
if _STDOUT_BACKUP_FD is None:
|
| 66 |
+
return
|
| 67 |
+
sys.stderr.flush()
|
| 68 |
+
os.dup2(_STDOUT_BACKUP_FD, 1)
|
| 69 |
+
os.close(_STDOUT_BACKUP_FD)
|
| 70 |
+
_STDOUT_BACKUP_FD = None
|
| 71 |
+
sys.stdout = os.fdopen(1, "wb", closefd=False)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _log(msg: str) -> None:
|
| 75 |
+
print(f"[kimodo-runner] {msg}", file=sys.stderr, flush=True)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _fail(msg: str, code: int = 1) -> "None":
|
| 79 |
+
_log(f"FAIL: {msg}")
|
| 80 |
+
sys.exit(code)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Sentinel checks — defence-in-depth (escape_hatch.invoke gates first, but the
|
| 85 |
+
# runner must also refuse to start if the venv build hasn't finished or failed).
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
_EXTERNAL_DIR = Path(
|
| 89 |
+
os.environ.get(
|
| 90 |
+
"ANIMOFLOW_EXTERNAL_DIR",
|
| 91 |
+
"/home/user/app/external"
|
| 92 |
+
if os.path.isdir("/home/user/app")
|
| 93 |
+
else str(Path(__file__).resolve().parent.parent / "external"),
|
| 94 |
+
)
|
| 95 |
+
)
|
| 96 |
+
_READY_SENTINEL = _EXTERNAL_DIR / ".kimodo_ready"
|
| 97 |
+
_FAILED_SENTINEL = _EXTERNAL_DIR / ".kimodo_failed"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _check_sentinels() -> None:
|
| 101 |
+
if _FAILED_SENTINEL.is_file():
|
| 102 |
+
body = ""
|
| 103 |
+
try:
|
| 104 |
+
body = _FAILED_SENTINEL.read_text()[:1200]
|
| 105 |
+
except OSError:
|
| 106 |
+
pass
|
| 107 |
+
_fail(
|
| 108 |
+
f"Kimodo venv build previously failed:\n{body}\n"
|
| 109 |
+
f"Delete {_FAILED_SENTINEL} and restart the Space to retry."
|
| 110 |
+
)
|
| 111 |
+
if not _READY_SENTINEL.is_file():
|
| 112 |
+
_fail(
|
| 113 |
+
f"Kimodo venv not ready (sentinel missing: {_READY_SENTINEL}). "
|
| 114 |
+
"The bootstrap thread may still be running."
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
# Import the AnimoFlow-owned helpers from comfyui-animoflow/containers/kimodo/app.py.
|
| 120 |
+
#
|
| 121 |
+
# The container's app.py constructs a FastAPI app at module top, but nothing
|
| 122 |
+
# serves it — importing it has no runtime cost beyond the import statements.
|
| 123 |
+
# We reuse its _patch_llm2vec_for_ungated_llama, _load_model, and
|
| 124 |
+
# _motion_to_output helpers verbatim per [[Wrap, don't fork upstream
|
| 125 |
+
# model repos]] (this file is AnimoFlow-authored, not upstream NVIDIA).
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _load_kimodo_app_module():
|
| 130 |
+
comfy_root = os.environ.get("COMFYUI_ANIMOFLOW_DIR")
|
| 131 |
+
if not comfy_root:
|
| 132 |
+
_fail(
|
| 133 |
+
"COMFYUI_ANIMOFLOW_DIR not set — the runner can't locate the "
|
| 134 |
+
"containers/kimodo/app.py helper module."
|
| 135 |
+
)
|
| 136 |
+
container_dir = Path(comfy_root) / "containers" / "kimodo"
|
| 137 |
+
app_py = container_dir / "app.py"
|
| 138 |
+
if not app_py.is_file():
|
| 139 |
+
_fail(f"containers/kimodo/app.py not found at {app_py}")
|
| 140 |
+
|
| 141 |
+
# Make `soma_smpl22_bvh` (a sibling module of app.py) importable.
|
| 142 |
+
if str(container_dir) not in sys.path:
|
| 143 |
+
sys.path.insert(0, str(container_dir))
|
| 144 |
+
# Make the Kimodo source tree importable.
|
| 145 |
+
kimodo_src = os.environ.get("KIMODO_SRC_DIR")
|
| 146 |
+
if kimodo_src and kimodo_src not in sys.path:
|
| 147 |
+
sys.path.insert(0, kimodo_src)
|
| 148 |
+
|
| 149 |
+
spec = importlib.util.spec_from_file_location("kimodo_helpers", app_py)
|
| 150 |
+
if spec is None or spec.loader is None:
|
| 151 |
+
_fail(f"importlib failed to build spec for {app_py}")
|
| 152 |
+
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
| 153 |
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
| 154 |
+
return mod
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# Memoize the loaded model across calls in the same subprocess. In practice
|
| 158 |
+
# this is one call per @spaces.GPU subprocess, but cheap insurance.
|
| 159 |
+
_KIMODO_MODEL = None
|
| 160 |
+
_KIMODO_HELPERS = None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _ensure_model_loaded():
|
| 164 |
+
global _KIMODO_MODEL, _KIMODO_HELPERS
|
| 165 |
+
if _KIMODO_MODEL is not None:
|
| 166 |
+
return _KIMODO_MODEL, _KIMODO_HELPERS
|
| 167 |
+
|
| 168 |
+
_KIMODO_HELPERS = _load_kimodo_app_module()
|
| 169 |
+
_log(f"loaded helpers from containers/kimodo/app.py (device={_KIMODO_HELPERS.DEVICE})")
|
| 170 |
+
|
| 171 |
+
# _load_model populates the module-global _model AND patches LLM2Vec configs
|
| 172 |
+
# to point at the ungated LLaMA mirror. Idempotent.
|
| 173 |
+
_KIMODO_HELPERS._load_model()
|
| 174 |
+
_KIMODO_MODEL = _KIMODO_HELPERS._model
|
| 175 |
+
if _KIMODO_MODEL is None:
|
| 176 |
+
_fail("_load_model() returned but kimodo_helpers._model is still None")
|
| 177 |
+
_log(f"model ready: {type(_KIMODO_MODEL).__name__}")
|
| 178 |
+
return _KIMODO_MODEL, _KIMODO_HELPERS
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ---------------------------------------------------------------------------
|
| 182 |
+
# Main
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def main() -> int:
|
| 187 |
+
_check_sentinels()
|
| 188 |
+
|
| 189 |
+
# Read stdin payload BEFORE diverting stdout (we don't write to stdout
|
| 190 |
+
# while reading).
|
| 191 |
+
try:
|
| 192 |
+
payload = json.loads(sys.stdin.read())
|
| 193 |
+
except Exception as exc: # noqa: BLE001
|
| 194 |
+
_fail(f"could not parse stdin JSON: {exc}")
|
| 195 |
+
return 2 # unreachable — _fail exits, but keeps type-checkers happy
|
| 196 |
+
|
| 197 |
+
# From here on, every library print() must go to stderr until we're
|
| 198 |
+
# ready to dump the NPZ bytes. Kimodo / transformers / py-soma-x all
|
| 199 |
+
# print to stdout (model load banners, "[Kimodo] patching adapter_config"
|
| 200 |
+
# etc.) — without this diversion the NPZ payload is prepended with text
|
| 201 |
+
# and `np.load(BytesIO(stdout_bytes))` fails with "Failed to interpret
|
| 202 |
+
# file as a pickle".
|
| 203 |
+
_divert_stdout_to_stderr()
|
| 204 |
+
|
| 205 |
+
prompt = payload.get("prompt")
|
| 206 |
+
num_frames = payload.get("num_frames")
|
| 207 |
+
seed = payload.get("seed")
|
| 208 |
+
if not isinstance(prompt, str) or not prompt.strip():
|
| 209 |
+
_fail("payload.prompt missing or empty")
|
| 210 |
+
if not isinstance(num_frames, int) or num_frames <= 0:
|
| 211 |
+
_fail(f"payload.num_frames missing or non-positive: {num_frames!r}")
|
| 212 |
+
if not isinstance(seed, int):
|
| 213 |
+
_fail(f"payload.seed missing or not an int: {seed!r}")
|
| 214 |
+
|
| 215 |
+
num_denoising_steps = int(payload.get("num_denoising_steps", 100))
|
| 216 |
+
# Kimodo's cfg_weight is [cfg_text, cfg_constraint]. The animoflow-api UI
|
| 217 |
+
# exposes a single `cfg` knob which pipeline_hf maps to both. The runner
|
| 218 |
+
# accepts either explicit split values or the single guidance_param.
|
| 219 |
+
if "cfg_text" in payload or "cfg_constraint" in payload:
|
| 220 |
+
cfg_text = float(payload.get("cfg_text", 2.0))
|
| 221 |
+
cfg_constraint = float(payload.get("cfg_constraint", 2.0))
|
| 222 |
+
else:
|
| 223 |
+
gp = payload.get("guidance_param")
|
| 224 |
+
cfg = float(gp) if gp is not None else 2.0
|
| 225 |
+
cfg_text = cfg
|
| 226 |
+
cfg_constraint = cfg
|
| 227 |
+
|
| 228 |
+
# Unified root2d constraint covers both trajectory (dense) and waypoint
|
| 229 |
+
# (sparse) tasks. pipeline_hf builds the dict; we forward it to Kimodo's
|
| 230 |
+
# constraint loader. Fail-fast on malformed shapes — no silent fallback.
|
| 231 |
+
root2d = payload.get("root2d")
|
| 232 |
+
if root2d is not None:
|
| 233 |
+
if not isinstance(root2d, dict):
|
| 234 |
+
_fail(f"payload.root2d must be a dict, got {type(root2d).__name__}")
|
| 235 |
+
frame_indices = root2d.get("frame_indices")
|
| 236 |
+
smooth_root_2d = root2d.get("smooth_root_2d")
|
| 237 |
+
if not isinstance(frame_indices, list) or not frame_indices:
|
| 238 |
+
_fail("payload.root2d.frame_indices must be a non-empty list of ints")
|
| 239 |
+
if not isinstance(smooth_root_2d, list) or not smooth_root_2d:
|
| 240 |
+
_fail("payload.root2d.smooth_root_2d must be a non-empty list of [x,z] pairs")
|
| 241 |
+
if len(frame_indices) != len(smooth_root_2d):
|
| 242 |
+
_fail(
|
| 243 |
+
f"payload.root2d length mismatch: frame_indices={len(frame_indices)} "
|
| 244 |
+
f"vs smooth_root_2d={len(smooth_root_2d)}"
|
| 245 |
+
)
|
| 246 |
+
for i, p in enumerate(smooth_root_2d):
|
| 247 |
+
if not (isinstance(p, list) and len(p) == 2):
|
| 248 |
+
_fail(f"payload.root2d.smooth_root_2d[{i}] must be [x, z]; got {p!r}")
|
| 249 |
+
max_frame = max(frame_indices)
|
| 250 |
+
if max_frame >= num_frames or min(frame_indices) < 0:
|
| 251 |
+
_fail(
|
| 252 |
+
f"payload.root2d.frame_indices out of range [0, {num_frames}): "
|
| 253 |
+
f"min={min(frame_indices)} max={max_frame}"
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
model, helpers = _ensure_model_loaded()
|
| 257 |
+
|
| 258 |
+
# Match the container's _generate_worker call path (containers/kimodo/app.py:472-481).
|
| 259 |
+
import torch
|
| 260 |
+
from kimodo.tools import seed_everything
|
| 261 |
+
|
| 262 |
+
seed_everything(int(seed))
|
| 263 |
+
|
| 264 |
+
# Build the constraint_lst kwarg. load_constraints_lst takes either a JSON
|
| 265 |
+
# path or a list[dict] directly; we pass a list[dict] to avoid file I/O.
|
| 266 |
+
# See kimodo/constraints.py:566-593 (TYPE_TO_CLASS["root2d"] → Root2DConstraintSet).
|
| 267 |
+
constraint_lst: list = []
|
| 268 |
+
if root2d is not None:
|
| 269 |
+
from kimodo.constraints import load_constraints_lst
|
| 270 |
+
dev = next(model.parameters()).device if hasattr(model, "parameters") else None
|
| 271 |
+
constraint_lst = load_constraints_lst(
|
| 272 |
+
[{"type": "root2d",
|
| 273 |
+
"frame_indices": [int(f) for f in root2d["frame_indices"]],
|
| 274 |
+
"smooth_root_2d": [[float(x), float(z)] for (x, z) in root2d["smooth_root_2d"]]}],
|
| 275 |
+
model.skeleton,
|
| 276 |
+
device=dev,
|
| 277 |
+
dtype=torch.float32,
|
| 278 |
+
)
|
| 279 |
+
_log(
|
| 280 |
+
f"root2d constraint: {len(root2d['frame_indices'])} frames "
|
| 281 |
+
f"(min={min(root2d['frame_indices'])} max={max(root2d['frame_indices'])}) "
|
| 282 |
+
f"XZ range x=[{min(p[0] for p in root2d['smooth_root_2d']):.2f},"
|
| 283 |
+
f"{max(p[0] for p in root2d['smooth_root_2d']):.2f}] "
|
| 284 |
+
f"z=[{min(p[1] for p in root2d['smooth_root_2d']):.2f},"
|
| 285 |
+
f"{max(p[1] for p in root2d['smooth_root_2d']):.2f}]"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
_log(
|
| 289 |
+
f"generating: prompt={prompt[:60]!r} num_frames={num_frames} "
|
| 290 |
+
f"seed={seed} steps={num_denoising_steps} cfg=[{cfg_text},{cfg_constraint}] "
|
| 291 |
+
f"constraints={len(constraint_lst)}"
|
| 292 |
+
)
|
| 293 |
+
with torch.no_grad():
|
| 294 |
+
output = model(
|
| 295 |
+
prompts=prompt,
|
| 296 |
+
num_frames=num_frames,
|
| 297 |
+
num_denoising_steps=num_denoising_steps,
|
| 298 |
+
cfg_weight=[cfg_text, cfg_constraint],
|
| 299 |
+
constraint_lst=constraint_lst,
|
| 300 |
+
return_numpy=False,
|
| 301 |
+
post_processing=False,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# SOMA → 22-joint rig-ready BVH (SMPL-free rotation-carrying calibrated path).
|
| 305 |
+
# The rotations carry the pose, so this feeds the rig stage directly — the
|
| 306 |
+
# orchestrator's plan skips IK for Kimodo.
|
| 307 |
+
data, is_npz = helpers._motion_to_output(output, model.output_skeleton, fmt="bvh22")
|
| 308 |
+
if is_npz:
|
| 309 |
+
_fail("_motion_to_output returned is_npz=True for fmt='bvh22' — expected a BVH string")
|
| 310 |
+
if not isinstance(data, str) or not data:
|
| 311 |
+
_fail(f"_motion_to_output returned empty/non-str BVH payload: {type(data)}")
|
| 312 |
+
|
| 313 |
+
# Defensive: sanity-check the BVH header before handing it to the rig stage.
|
| 314 |
+
bvh_bytes = data.encode()
|
| 315 |
+
nframes = next((int(l.split(":", 1)[1]) for l in data.splitlines()
|
| 316 |
+
if l.startswith("Frames:")), None)
|
| 317 |
+
if "HIERARCHY" not in data or nframes is None:
|
| 318 |
+
_fail("bvh22 output missing HIERARCHY / Frames header")
|
| 319 |
+
_log(f"output: BVH {nframes} frames ({len(bvh_bytes)//1024} KB)")
|
| 320 |
+
|
| 321 |
+
# Restore the saved stdout fd and write the BVH payload as raw bytes.
|
| 322 |
+
_restore_stdout_for_npz()
|
| 323 |
+
os.write(1, bvh_bytes)
|
| 324 |
+
return 0
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
if __name__ == "__main__":
|
| 328 |
+
try:
|
| 329 |
+
sys.exit(main())
|
| 330 |
+
except SystemExit:
|
| 331 |
+
raise
|
| 332 |
+
except Exception as exc: # noqa: BLE001
|
| 333 |
+
# Top-level guard so any uncaught error lands as a clear stderr trace
|
| 334 |
+
# rather than a silent non-zero exit. No silent fallback.
|
| 335 |
+
print(f"[kimodo-runner] FATAL UNCAUGHT: {type(exc).__name__}: {exc}", file=sys.stderr)
|
| 336 |
+
traceback.print_exc(file=sys.stderr)
|
| 337 |
+
sys.exit(1)
|
spaces_compat.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ZeroGPU compatibility shim.
|
| 3 |
+
|
| 4 |
+
In an HF Space with ZeroGPU, the `spaces` package is installed and
|
| 5 |
+
`@spaces.GPU(duration=N)` allocates an H200 to the decorated function for
|
| 6 |
+
its duration. Outside HF (any OSS local install, CI, tests), the package
|
| 7 |
+
isn't present — we fall back to a pass-through decorator so the same code
|
| 8 |
+
runs unchanged on any host.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
|
| 12 |
+
from spaces_compat import GPU
|
| 13 |
+
|
| 14 |
+
@GPU(duration=30)
|
| 15 |
+
def run_inference(...):
|
| 16 |
+
...
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import logging
|
| 22 |
+
import os
|
| 23 |
+
from functools import wraps
|
| 24 |
+
from typing import Any, Callable
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _is_huggingface_space() -> bool:
|
| 30 |
+
"""Heuristic: HF Spaces always set this env var."""
|
| 31 |
+
return bool(os.environ.get("SPACE_ID")) or bool(os.environ.get("SPACE_AUTHOR_NAME"))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
import spaces # type: ignore[import-not-found]
|
| 36 |
+
|
| 37 |
+
GPU = spaces.GPU # re-export
|
| 38 |
+
HAVE_SPACES = True
|
| 39 |
+
if _is_huggingface_space():
|
| 40 |
+
log.info("ZeroGPU active — @spaces.GPU will allocate H200 per call")
|
| 41 |
+
else:
|
| 42 |
+
log.info("`spaces` importable but not in HF Space — decorator is best-effort")
|
| 43 |
+
except ImportError: # pragma: no cover — exercised in OSS local
|
| 44 |
+
HAVE_SPACES = False
|
| 45 |
+
|
| 46 |
+
def GPU(*decorator_args: Any, **decorator_kwargs: Any) -> Callable: # type: ignore[no-redef]
|
| 47 |
+
"""Pass-through decorator for non-HF environments.
|
| 48 |
+
|
| 49 |
+
Supports both forms:
|
| 50 |
+
@GPU
|
| 51 |
+
def fn(...): ...
|
| 52 |
+
|
| 53 |
+
@GPU(duration=30)
|
| 54 |
+
def fn(...): ...
|
| 55 |
+
"""
|
| 56 |
+
# Bare-decorator form: @GPU on a function with no parens
|
| 57 |
+
if (
|
| 58 |
+
len(decorator_args) == 1
|
| 59 |
+
and callable(decorator_args[0])
|
| 60 |
+
and not decorator_kwargs
|
| 61 |
+
):
|
| 62 |
+
return decorator_args[0]
|
| 63 |
+
|
| 64 |
+
# Parameterized form: @GPU(...) returns a decorator
|
| 65 |
+
def decorator(fn: Callable) -> Callable:
|
| 66 |
+
@wraps(fn)
|
| 67 |
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
| 68 |
+
return fn(*args, **kwargs)
|
| 69 |
+
|
| 70 |
+
return wrapper
|
| 71 |
+
|
| 72 |
+
return decorator
|
| 73 |
+
|
| 74 |
+
log.info("`spaces` package not installed — GPU decorator is a no-op pass-through")
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pytest fixtures shared across animoflow-app tests.
|
| 3 |
+
|
| 4 |
+
Key constraint: tests must pass on a CPU laptop with no GPU, no model
|
| 5 |
+
weights, and (ideally) no Blender. We mock model inference and skip
|
| 6 |
+
end-to-end pipeline tests when blender / weights are absent.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
_HERE = Path(__file__).resolve().parent
|
| 18 |
+
_REPO = _HERE.parent
|
| 19 |
+
|
| 20 |
+
# Make app modules importable
|
| 21 |
+
if str(_REPO) not in sys.path:
|
| 22 |
+
sys.path.insert(0, str(_REPO))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture(scope="session", autouse=True)
|
| 26 |
+
def _set_test_env():
|
| 27 |
+
"""Set test-friendly env vars before any module imports.
|
| 28 |
+
|
| 29 |
+
Uses a well-known sub-path of /tmp instead of pytest's tmp_path_factory
|
| 30 |
+
because some FUSE-mounted sandboxes throw on rmdir of session tmpdirs,
|
| 31 |
+
sending pytest's cleanup into recursion. Stable per-test-run path is
|
| 32 |
+
safer and behaves identically across hosts.
|
| 33 |
+
"""
|
| 34 |
+
test_root = Path("/tmp/animoflow-app-test")
|
| 35 |
+
os.environ["OUTPUT_DIR"] = str(test_root / "output")
|
| 36 |
+
os.environ.setdefault("WEB_DIR", "/nonexistent")
|
| 37 |
+
os.environ.setdefault("CHECKPOINTS_DIR", str(test_root / "checkpoints"))
|
| 38 |
+
os.environ.setdefault("HEALTH_POLL_INTERVAL", "999999") # don't poll during tests
|
| 39 |
+
Path(os.environ["OUTPUT_DIR"]).mkdir(parents=True, exist_ok=True)
|
| 40 |
+
Path(os.environ["CHECKPOINTS_DIR"]).mkdir(parents=True, exist_ok=True)
|
| 41 |
+
yield
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _can_import(name: str) -> bool:
|
| 45 |
+
try:
|
| 46 |
+
__import__(name)
|
| 47 |
+
except Exception: # noqa: BLE001 — we want all import errors to count
|
| 48 |
+
return False
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
HAS_GRADIO = _can_import("gradio")
|
| 53 |
+
HAS_FASTAPI = _can_import("fastapi")
|
| 54 |
+
HAS_HTTPX = _can_import("httpx")
|
| 55 |
+
HAS_TORCH = _can_import("torch")
|
| 56 |
+
HAS_NUMPY = _can_import("numpy")
|
| 57 |
+
|
| 58 |
+
ANIMOFLOW_API_API = Path(
|
| 59 |
+
os.environ.get("ANIMOFLOW_API_API_DIR", "/opt/animoflow-api/api")
|
| 60 |
+
)
|
| 61 |
+
HAS_ANIMOFLOW_API = ANIMOFLOW_API_API.is_dir()
|
| 62 |
+
|
| 63 |
+
COMFYUI_ANIMOFLOW = Path(
|
| 64 |
+
os.environ.get("COMFYUI_ANIMOFLOW_DIR", "/opt/comfyui-animoflow")
|
| 65 |
+
)
|
| 66 |
+
HAS_COMFYUI_ANIMOFLOW = COMFYUI_ANIMOFLOW.is_dir()
|
| 67 |
+
|
| 68 |
+
BLENDER_BIN = Path(os.environ.get("BLENDER_BIN", "/opt/blender/blender"))
|
| 69 |
+
HAS_BLENDER = BLENDER_BIN.exists()
|
| 70 |
+
|
tests/test_app_imports.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Import-shape tests — verify app.py wires up without crashing on a CPU laptop.
|
| 3 |
+
|
| 4 |
+
These tests skip cleanly when the sibling repos (animoflow-api,
|
| 5 |
+
comfyui-animoflow) aren't cloned at the expected paths. On a real Docker
|
| 6 |
+
build they're always present, so this is the canary that catches our own
|
| 7 |
+
import-path mistakes.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from .conftest import HAS_FASTAPI, HAS_GRADIO, HAS_HTTPX, HAS_ANIMOFLOW_API
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.mark.skipif(not HAS_GRADIO, reason="gradio not installed")
|
| 21 |
+
def test_spaces_compat_imports():
|
| 22 |
+
"""The compat shim must always import on its own, even without `spaces`."""
|
| 23 |
+
import importlib
|
| 24 |
+
|
| 25 |
+
import spaces_compat
|
| 26 |
+
|
| 27 |
+
importlib.reload(spaces_compat)
|
| 28 |
+
assert callable(spaces_compat.GPU)
|
| 29 |
+
assert isinstance(spaces_compat.HAVE_SPACES, bool)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@pytest.mark.skipif(
|
| 33 |
+
not (HAS_FASTAPI and HAS_GRADIO and HAS_HTTPX and HAS_ANIMOFLOW_API),
|
| 34 |
+
reason="needs fastapi + gradio + httpx + a cloned animoflow-api at /opt/animoflow-api",
|
| 35 |
+
)
|
| 36 |
+
def test_app_module_imports():
|
| 37 |
+
"""app.py should import without crashing — exercises the monkeypatch order."""
|
| 38 |
+
# This is the actual Docker boot path, just minus uvicorn.
|
| 39 |
+
import importlib
|
| 40 |
+
|
| 41 |
+
if "app" in sys.modules:
|
| 42 |
+
importlib.reload(sys.modules["app"])
|
| 43 |
+
else:
|
| 44 |
+
import app # noqa: F401
|
| 45 |
+
|
| 46 |
+
import app
|
| 47 |
+
|
| 48 |
+
assert app.fastapi_app is not None
|
| 49 |
+
# Verify the monkeypatch happened
|
| 50 |
+
import pipeline as animoflow_api_pipeline
|
| 51 |
+
import pipeline_hf
|
| 52 |
+
|
| 53 |
+
assert animoflow_api_pipeline.run is pipeline_hf.run
|
| 54 |
+
assert animoflow_api_pipeline.run_timeline is pipeline_hf.run_timeline
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.skipif(
|
| 58 |
+
not (HAS_FASTAPI and HAS_GRADIO and HAS_HTTPX and HAS_ANIMOFLOW_API),
|
| 59 |
+
reason="needs fastapi + gradio + httpx + animoflow-api",
|
| 60 |
+
)
|
| 61 |
+
def test_v1_health_route_exists():
|
| 62 |
+
"""The /v1/health route from animoflow-api should still be present after
|
| 63 |
+
Gradio is mounted on '/'."""
|
| 64 |
+
import app
|
| 65 |
+
|
| 66 |
+
routes = [getattr(r, "path", None) for r in app.fastapi_app.routes]
|
| 67 |
+
assert "/v1/health" in routes, f"missing /v1/health, got: {routes}"
|
tests/test_pipeline_hf.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline tests — exercise the HF pipeline shape with mocked inference.
|
| 3 |
+
|
| 4 |
+
We do NOT load real model weights here. The goal is to verify:
|
| 5 |
+
* The pipeline accepts the same call signature as animoflow-api/api/pipeline.py:run
|
| 6 |
+
* Errors propagate cleanly when deps are missing
|
| 7 |
+
* Stage callbacks fire in the expected order
|
| 8 |
+
* The escape-hatch routing logic recognizes outliers correctly
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_signature_compat_with_animoflow_api_pipeline():
|
| 17 |
+
"""The HF pipeline must accept every kwarg the original pipeline.run does.
|
| 18 |
+
|
| 19 |
+
If this drifts, animoflow-api/api/main.py:_run_pipeline will start
|
| 20 |
+
passing kwargs that pipeline_hf.run rejects.
|
| 21 |
+
"""
|
| 22 |
+
import inspect
|
| 23 |
+
|
| 24 |
+
import pipeline_hf
|
| 25 |
+
|
| 26 |
+
sig = inspect.signature(pipeline_hf.run)
|
| 27 |
+
params = sig.parameters
|
| 28 |
+
|
| 29 |
+
# These are all the kwargs animoflow-api's main.py:_run_pipeline passes.
|
| 30 |
+
required = {
|
| 31 |
+
"job_id",
|
| 32 |
+
"prompt",
|
| 33 |
+
"num_frames",
|
| 34 |
+
"seed",
|
| 35 |
+
"character",
|
| 36 |
+
"model",
|
| 37 |
+
"on_progress",
|
| 38 |
+
"on_stage",
|
| 39 |
+
"preprocess_stages",
|
| 40 |
+
"keyframe_builder",
|
| 41 |
+
"keyframe_builder_error_degrees",
|
| 42 |
+
"output_fps",
|
| 43 |
+
"curve_2d",
|
| 44 |
+
"accel_frac",
|
| 45 |
+
"decel_frac",
|
| 46 |
+
}
|
| 47 |
+
missing = required - set(params.keys())
|
| 48 |
+
assert not missing, f"pipeline_hf.run is missing kwargs: {missing}"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_run_timeline_signature():
|
| 52 |
+
import inspect
|
| 53 |
+
|
| 54 |
+
import pipeline_hf
|
| 55 |
+
|
| 56 |
+
sig = inspect.signature(pipeline_hf.run_timeline)
|
| 57 |
+
params = sig.parameters
|
| 58 |
+
required = {
|
| 59 |
+
"job_id",
|
| 60 |
+
"segments",
|
| 61 |
+
"seed",
|
| 62 |
+
"character",
|
| 63 |
+
"cfg",
|
| 64 |
+
"handshake_size",
|
| 65 |
+
"blend_len",
|
| 66 |
+
"on_progress",
|
| 67 |
+
"on_stage",
|
| 68 |
+
"keyframe_builder",
|
| 69 |
+
"keyframe_builder_error_degrees",
|
| 70 |
+
"output_fps",
|
| 71 |
+
}
|
| 72 |
+
missing = required - set(params.keys())
|
| 73 |
+
assert not missing, f"pipeline_hf.run_timeline is missing kwargs: {missing}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _point_at_sibling_checkout(monkeypatch):
|
| 77 |
+
"""Point pipeline_hf at the sibling comfyui-animoflow checkout so the
|
| 78 |
+
shared stage library loads on dev machines (the Space uses
|
| 79 |
+
/opt/comfyui-animoflow). Skip when neither exists."""
|
| 80 |
+
from pathlib import Path
|
| 81 |
+
|
| 82 |
+
import pipeline_hf
|
| 83 |
+
|
| 84 |
+
sibling = Path(__file__).resolve().parents[2] / "comfyui-animoflow"
|
| 85 |
+
if (sibling / "animoflow_stages" / "__init__.py").is_file():
|
| 86 |
+
monkeypatch.setattr(pipeline_hf, "_COMFY_ROOT", sibling)
|
| 87 |
+
monkeypatch.setattr(pipeline_hf, "_stage_lib", None)
|
| 88 |
+
elif not (pipeline_hf._COMFY_ROOT / "animoflow_stages" / "__init__.py").is_file():
|
| 89 |
+
pytest.skip("no comfyui-animoflow checkout with animoflow_stages available")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_run_timeline_wires_to_priormdm(monkeypatch):
|
| 93 |
+
"""run_timeline must call _run_timeline_gpu, unwrap the result, and
|
| 94 |
+
pass through to the shared post-processing tail. We mock both ends so
|
| 95 |
+
the test doesn't load real model weights or invoke Blender."""
|
| 96 |
+
import pipeline_hf
|
| 97 |
+
|
| 98 |
+
captured: dict = {}
|
| 99 |
+
|
| 100 |
+
def _fake_gpu(segments, seed, *, guidance_param=2.5,
|
| 101 |
+
handshake_size=10, blend_len=10):
|
| 102 |
+
captured["segments"] = segments
|
| 103 |
+
captured["seed"] = seed
|
| 104 |
+
captured["guidance_param"] = guidance_param
|
| 105 |
+
captured["handshake_size"] = handshake_size
|
| 106 |
+
captured["blend_len"] = blend_len
|
| 107 |
+
return b"FAKE_NPZ_BYTES_NO_SENTINEL"
|
| 108 |
+
|
| 109 |
+
def _fake_postprocess(**kw):
|
| 110 |
+
captured["postprocess_kwargs"] = kw
|
| 111 |
+
return "fake.fbx"
|
| 112 |
+
|
| 113 |
+
monkeypatch.setattr(pipeline_hf, "_run_timeline_gpu", _fake_gpu)
|
| 114 |
+
monkeypatch.setattr(pipeline_hf, "_run_tail", _fake_postprocess)
|
| 115 |
+
_point_at_sibling_checkout(monkeypatch)
|
| 116 |
+
# The whole tail (incl. resample) is mocked out via _run_tail, so the
|
| 117 |
+
# fake NPZ bytes are never parsed.
|
| 118 |
+
out = pipeline_hf.run_timeline(
|
| 119 |
+
job_id="t1",
|
| 120 |
+
segments=[{"prompt": "wave", "num_frames": 80},
|
| 121 |
+
{"prompt": "jump", "num_frames": 60}],
|
| 122 |
+
seed=42,
|
| 123 |
+
cfg=2.5,
|
| 124 |
+
output_fps=20,
|
| 125 |
+
)
|
| 126 |
+
assert out == "fake.fbx"
|
| 127 |
+
assert captured["segments"][0]["prompt"] == "wave"
|
| 128 |
+
assert captured["seed"] == 42
|
| 129 |
+
assert captured["guidance_param"] == 2.5
|
| 130 |
+
assert captured["postprocess_kwargs"]["npz_bytes"] == b"FAKE_NPZ_BYTES_NO_SENTINEL"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_run_priormdm_forwards_curve_2d_to_gpu(monkeypatch):
|
| 134 |
+
"""run(model='priormdm') must forward curve_2d + accel/decel_frac into
|
| 135 |
+
the GPU call's extra-kwargs so the priorMDM wrapper sees them."""
|
| 136 |
+
import pipeline_hf
|
| 137 |
+
|
| 138 |
+
captured: dict = {}
|
| 139 |
+
|
| 140 |
+
def _fake_gpu(model, prompt, num_frames, seed, *,
|
| 141 |
+
guidance_param=None, **extra):
|
| 142 |
+
captured["model"] = model
|
| 143 |
+
captured["prompt"] = prompt
|
| 144 |
+
captured.update(extra)
|
| 145 |
+
return b"FAKE_NPZ_BYTES_NO_SENTINEL"
|
| 146 |
+
|
| 147 |
+
def _fake_postprocess(**kw):
|
| 148 |
+
return "fake.fbx"
|
| 149 |
+
|
| 150 |
+
monkeypatch.setattr(pipeline_hf, "_run_inference_gpu_fast", _fake_gpu)
|
| 151 |
+
monkeypatch.setattr(pipeline_hf, "_run_tail", _fake_postprocess)
|
| 152 |
+
_point_at_sibling_checkout(monkeypatch)
|
| 153 |
+
out = pipeline_hf.run(
|
| 154 |
+
job_id="p1",
|
| 155 |
+
prompt="walk forward",
|
| 156 |
+
num_frames=80,
|
| 157 |
+
seed=0,
|
| 158 |
+
character="Y_bot",
|
| 159 |
+
model="priormdm",
|
| 160 |
+
curve_2d=[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]],
|
| 161 |
+
accel_frac=0.2,
|
| 162 |
+
decel_frac=0.3,
|
| 163 |
+
output_fps=20, # priormdm native, skip resample on the fake bytes
|
| 164 |
+
)
|
| 165 |
+
assert out == "fake.fbx"
|
| 166 |
+
assert captured["model"] == "priormdm"
|
| 167 |
+
assert captured["curve_2d"] == [[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]]
|
| 168 |
+
assert captured["accel_frac"] == 0.2
|
| 169 |
+
assert captured["decel_frac"] == 0.3
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_escape_hatch_recognizes_outliers():
|
| 173 |
+
"""Outlier registry should report unknown models as non-outliers."""
|
| 174 |
+
import escape_hatch
|
| 175 |
+
|
| 176 |
+
# The MDM family runs in-process (kimodo is the escape-hatch outlier)
|
| 177 |
+
assert escape_hatch.is_outlier("mdm") is False
|
| 178 |
+
assert escape_hatch.is_outlier("priormdm") is False
|
| 179 |
+
assert escape_hatch.is_outlier("not_a_real_model") is False
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_escape_hatch_raises_on_unregistered_invoke():
|
| 183 |
+
"""invoke() on a non-outlier model must raise a clear RuntimeError."""
|
| 184 |
+
import escape_hatch
|
| 185 |
+
|
| 186 |
+
with pytest.raises(RuntimeError) as excinfo:
|
| 187 |
+
escape_hatch.invoke("mdm", prompt="test", num_frames=60, seed=0)
|
| 188 |
+
assert "outlier" in str(excinfo.value).lower()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_numpy_compat_shim_installs_legacy_aliases():
|
| 192 |
+
"""Shim must add np.float / np.int / np.bool / numpy.core.umath_tests
|
| 193 |
+
even on numpy 2.x. Idempotent."""
|
| 194 |
+
import sys
|
| 195 |
+
|
| 196 |
+
import numpy as np
|
| 197 |
+
|
| 198 |
+
import pipeline_hf
|
| 199 |
+
|
| 200 |
+
# Run twice to confirm idempotency
|
| 201 |
+
pipeline_hf._install_numpy_compat_shim()
|
| 202 |
+
pipeline_hf._install_numpy_compat_shim()
|
| 203 |
+
|
| 204 |
+
# Legacy aliases present
|
| 205 |
+
assert hasattr(np, "float")
|
| 206 |
+
assert hasattr(np, "int")
|
| 207 |
+
assert hasattr(np, "bool")
|
| 208 |
+
assert hasattr(np, "object")
|
| 209 |
+
|
| 210 |
+
# umath_tests stub is registered
|
| 211 |
+
import numpy.core.umath_tests as ut
|
| 212 |
+
|
| 213 |
+
# inner1d → element-wise dot product
|
| 214 |
+
a = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
|
| 215 |
+
b = np.array([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]])
|
| 216 |
+
expected_inner1d = np.array([6.0, 30.0])
|
| 217 |
+
assert np.allclose(ut.inner1d(a, b), expected_inner1d), (
|
| 218 |
+
f"inner1d shim returned {ut.inner1d(a, b)!r}, expected {expected_inner1d!r}"
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
# matrix_multiply → np.matmul (stacked matrix mult)
|
| 222 |
+
M1 = np.array([[[1.0, 2.0], [3.0, 4.0]], [[2.0, 0.0], [0.0, 2.0]]])
|
| 223 |
+
M2 = np.array([[[5.0, 6.0], [7.0, 8.0]], [[1.0, 1.0], [1.0, 1.0]]])
|
| 224 |
+
expected_mm = np.matmul(M1, M2)
|
| 225 |
+
assert np.allclose(ut.matrix_multiply(M1, M2), expected_mm), (
|
| 226 |
+
"matrix_multiply shim diverged from np.matmul"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# innerwt → weighted inner product, sum_i a_i * b_i * w_i over last axis
|
| 230 |
+
w = np.array([[1.0, 2.0, 3.0], [3.0, 2.0, 1.0]])
|
| 231 |
+
# Row 0: 1·1·1 + 2·1·2 + 3·1·3 = 1+4+9 = 14
|
| 232 |
+
# Row 1: 4·2·3 + 5·2·2 + 6·2·1 = 24+20+12 = 56
|
| 233 |
+
expected_innerwt = np.array([14.0, 56.0])
|
| 234 |
+
assert np.allclose(ut.innerwt(a, b, w), expected_innerwt), (
|
| 235 |
+
f"innerwt shim returned {ut.innerwt(a, b, w)!r}, expected {expected_innerwt!r}"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Module is in sys.modules
|
| 239 |
+
assert "numpy.core.umath_tests" in sys.modules
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_pipeline_run_propagates_callbacks(monkeypatch):
|
| 243 |
+
"""Stage / progress callbacks should fire even when inference fails.
|
| 244 |
+
|
| 245 |
+
We monkeypatch _run_inference_gpu_fast (the MDM/MoMask GPU wrapper) so no
|
| 246 |
+
real model loads. The pipeline should call on_stage("Generating…") at
|
| 247 |
+
least once before failing.
|
| 248 |
+
"""
|
| 249 |
+
import pipeline_hf
|
| 250 |
+
|
| 251 |
+
stages: list[str] = []
|
| 252 |
+
|
| 253 |
+
def _on_stage(s: str):
|
| 254 |
+
stages.append(s)
|
| 255 |
+
|
| 256 |
+
def _fake_inference(*args, **kwargs): # noqa: ARG001
|
| 257 |
+
raise RuntimeError("synthetic — model not loaded")
|
| 258 |
+
|
| 259 |
+
monkeypatch.setattr(pipeline_hf, "_run_inference_gpu_fast", _fake_inference)
|
| 260 |
+
_point_at_sibling_checkout(monkeypatch)
|
| 261 |
+
|
| 262 |
+
with pytest.raises(RuntimeError):
|
| 263 |
+
pipeline_hf.run(
|
| 264 |
+
job_id="test-job-1",
|
| 265 |
+
prompt="test prompt",
|
| 266 |
+
num_frames=60,
|
| 267 |
+
seed=42,
|
| 268 |
+
character="Y_bot",
|
| 269 |
+
model="mdm",
|
| 270 |
+
on_stage=_on_stage,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
assert "Generating…" in stages, f"expected 'Generating…' to fire, got {stages!r}"
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def test_safe_do_inference_passes_through_npz_bytes(monkeypatch):
|
| 277 |
+
"""The sentinel wrapper must NOT modify successful return values."""
|
| 278 |
+
import pipeline_hf
|
| 279 |
+
|
| 280 |
+
monkeypatch.setattr(
|
| 281 |
+
pipeline_hf, "_do_inference",
|
| 282 |
+
lambda *a, **kw: b"FAKE_NPZ_BYTES_NO_SENTINEL",
|
| 283 |
+
)
|
| 284 |
+
out = pipeline_hf._safe_do_inference(
|
| 285 |
+
model="mdm", prompt="x", num_frames=60, seed=0,
|
| 286 |
+
)
|
| 287 |
+
assert out == b"FAKE_NPZ_BYTES_NO_SENTINEL"
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def test_safe_do_inference_serialises_exception_into_sentinel(monkeypatch):
|
| 291 |
+
"""When _do_inference raises, the wrapper must emit a sentinel-prefixed
|
| 292 |
+
JSON payload — not propagate the exception. This is the fix for the
|
| 293 |
+
'AppError: \\'RuntimeError\\'' Space symptom where ZeroGPU strips
|
| 294 |
+
exception args across the worker boundary."""
|
| 295 |
+
import json
|
| 296 |
+
|
| 297 |
+
import pipeline_hf
|
| 298 |
+
|
| 299 |
+
def _boom(*a, **kw): # noqa: ARG001
|
| 300 |
+
raise RuntimeError("Kimodo venv still warming up — please retry")
|
| 301 |
+
|
| 302 |
+
monkeypatch.setattr(pipeline_hf, "_do_inference", _boom)
|
| 303 |
+
|
| 304 |
+
out = pipeline_hf._safe_do_inference(
|
| 305 |
+
model="kimodo", prompt="x", num_frames=60, seed=0,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
assert out.startswith(pipeline_hf._ERR_SENTINEL), (
|
| 309 |
+
"wrapper must prefix the error payload with _ERR_SENTINEL"
|
| 310 |
+
)
|
| 311 |
+
payload = json.loads(out[len(pipeline_hf._ERR_SENTINEL):].decode())
|
| 312 |
+
assert payload["error_class"] == "RuntimeError"
|
| 313 |
+
assert "warming up" in payload["message"] # original message preserved
|
| 314 |
+
assert "traceback" in payload
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_unwrap_gpu_result_reraises_with_original_message():
|
| 318 |
+
"""Inverse of _safe_do_inference: detect the sentinel and re-raise the
|
| 319 |
+
original message as a RuntimeError on the orchestrator side."""
|
| 320 |
+
import json
|
| 321 |
+
|
| 322 |
+
import pipeline_hf
|
| 323 |
+
|
| 324 |
+
payload = json.dumps({
|
| 325 |
+
"error_class": "RuntimeError",
|
| 326 |
+
"message": "Kimodo venv still warming up — please retry",
|
| 327 |
+
"traceback": "fake-traceback",
|
| 328 |
+
}).encode()
|
| 329 |
+
sentinel_blob = pipeline_hf._ERR_SENTINEL + payload
|
| 330 |
+
|
| 331 |
+
with pytest.raises(RuntimeError) as excinfo:
|
| 332 |
+
pipeline_hf._unwrap_gpu_result(sentinel_blob)
|
| 333 |
+
assert "warming up" in str(excinfo.value)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def test_unwrap_gpu_result_passes_through_real_bytes():
|
| 337 |
+
"""No sentinel → pass through unchanged."""
|
| 338 |
+
import pipeline_hf
|
| 339 |
+
|
| 340 |
+
real = b"PK\x03\x04...npz bytes..." # any non-sentinel byte string
|
| 341 |
+
assert pipeline_hf._unwrap_gpu_result(real) is real
|
tests/test_spaces_compat.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test the @spaces.GPU compat shim.
|
| 3 |
+
|
| 4 |
+
Critical correctness: the decorator must work in BOTH forms (`@GPU` and
|
| 5 |
+
`@GPU(duration=N)`) on a non-HF environment, transparently passing the
|
| 6 |
+
function through. If this is wrong, every OSS local install breaks.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_GPU_parameterized_form_passes_through():
|
| 15 |
+
"""`@GPU(duration=30)` should call the underlying function with all args."""
|
| 16 |
+
from spaces_compat import GPU
|
| 17 |
+
|
| 18 |
+
@GPU(duration=30)
|
| 19 |
+
def fn(a, b):
|
| 20 |
+
return a + b
|
| 21 |
+
|
| 22 |
+
assert fn(2, 3) == 5
|
| 23 |
+
assert fn(a=4, b=10) == 14
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_GPU_bare_form_passes_through():
|
| 27 |
+
"""`@GPU` (no parens) should still work as a bare decorator."""
|
| 28 |
+
from spaces_compat import GPU
|
| 29 |
+
|
| 30 |
+
@GPU
|
| 31 |
+
def fn(x):
|
| 32 |
+
return x * 2
|
| 33 |
+
|
| 34 |
+
assert fn(7) == 14
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_GPU_kwargs_only_form():
|
| 38 |
+
"""`@GPU(duration=30, ...)` accepts arbitrary kwargs without breaking."""
|
| 39 |
+
from spaces_compat import GPU
|
| 40 |
+
|
| 41 |
+
@GPU(duration=60, queue=False)
|
| 42 |
+
def fn():
|
| 43 |
+
return "ok"
|
| 44 |
+
|
| 45 |
+
assert fn() == "ok"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_GPU_preserves_metadata():
|
| 49 |
+
"""The decorated function should keep its name/docstring (functools.wraps)."""
|
| 50 |
+
from spaces_compat import GPU
|
| 51 |
+
|
| 52 |
+
@GPU(duration=10)
|
| 53 |
+
def my_function():
|
| 54 |
+
"""An important docstring."""
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
# In the spaces-installed branch we re-export spaces.GPU directly which
|
| 58 |
+
# may or may not preserve metadata depending on spaces' implementation.
|
| 59 |
+
# We only assert metadata preservation when the OSS fallback is active.
|
| 60 |
+
import spaces_compat
|
| 61 |
+
|
| 62 |
+
if not spaces_compat.HAVE_SPACES:
|
| 63 |
+
assert my_function.__name__ == "my_function"
|
| 64 |
+
assert "important" in (my_function.__doc__ or "")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_GPU_does_not_swallow_exceptions():
|
| 68 |
+
"""If the wrapped function raises, the wrapper must re-raise."""
|
| 69 |
+
from spaces_compat import GPU
|
| 70 |
+
|
| 71 |
+
class _Boom(Exception):
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
+
@GPU(duration=5)
|
| 75 |
+
def bad():
|
| 76 |
+
raise _Boom("kaboom")
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
bad()
|
| 80 |
+
except _Boom:
|
| 81 |
+
return
|
| 82 |
+
raise AssertionError("decorator swallowed the exception")
|
ui.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio Blocks UI for the AnimoFlow HF Space.
|
| 3 |
+
|
| 4 |
+
The UI is a thin client over the FastAPI /v1/jobs endpoint that the
|
| 5 |
+
animoflow-api app already implements. We POST a job, poll for status,
|
| 6 |
+
then download the resulting FBX/GLB.
|
| 7 |
+
|
| 8 |
+
Same surface that the animoflow.ai webpage and Blender add-on consume.
|
| 9 |
+
The UI itself uses no privileged path — anything you can do here, you
|
| 10 |
+
can do via curl.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
import time
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import httpx
|
| 24 |
+
|
| 25 |
+
log = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
# Usage analytics (animoflow-api module; on sys.path via app.py step 1).
|
| 28 |
+
# No-op unless USAGE_LOG_* env vars are set — see usage_log.py docstring.
|
| 29 |
+
try:
|
| 30 |
+
import usage_log
|
| 31 |
+
except ImportError: # bare ui.py import outside the Space wiring (tests)
|
| 32 |
+
usage_log = None
|
| 33 |
+
log.warning("usage_log not importable — usage analytics disabled")
|
| 34 |
+
|
| 35 |
+
# Where animoflow-api's /v1 lives. In-process the FastAPI app is mounted at
|
| 36 |
+
# the same root, so http://localhost:<port> works.
|
| 37 |
+
_PORT = int(os.environ.get("PORT", "7860"))
|
| 38 |
+
_API_BASE = os.environ.get("ANIMOFLOW_API_BASE", f"http://127.0.0.1:{_PORT}")
|
| 39 |
+
_API_KEY = os.environ.get("ANIMOFLOW_API_KEY", "dev")
|
| 40 |
+
|
| 41 |
+
_OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/tmp/animoflow-output"))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _post_job_body(body: dict[str, Any], headers: dict[str, str] | None = None) -> str:
|
| 45 |
+
"""POST /v1/jobs with a pre-built body → job_id. Raises on non-2xx.
|
| 46 |
+
|
| 47 |
+
``headers`` lets callers add attribution (x-usage-actor) on the internal
|
| 48 |
+
loopback hop without touching the auth header.
|
| 49 |
+
"""
|
| 50 |
+
r = httpx.post(
|
| 51 |
+
f"{_API_BASE}/v1/jobs",
|
| 52 |
+
json=body,
|
| 53 |
+
headers={"Authorization": f"Bearer {_API_KEY}", **(headers or {})},
|
| 54 |
+
timeout=15.0,
|
| 55 |
+
)
|
| 56 |
+
r.raise_for_status()
|
| 57 |
+
return r.json()["job_id"]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _submit_error(exc: httpx.HTTPStatusError) -> tuple[str, str]:
|
| 61 |
+
"""(user_message, error_code) for a non-2xx POST /v1/jobs.
|
| 62 |
+
|
| 63 |
+
Prefer the API's own classified message (error_classify populates it on
|
| 64 |
+
clean failures); otherwise fall back to a neutral per-status message.
|
| 65 |
+
Never interpolate the raw exception — it carries internal URLs
|
| 66 |
+
(e.g. http://127.0.0.1:7860/v1/jobs) that must not reach users.
|
| 67 |
+
"""
|
| 68 |
+
try:
|
| 69 |
+
err = exc.response.json().get("error", {})
|
| 70 |
+
except Exception: # noqa: BLE001 — non-JSON body (e.g. bare 500 page)
|
| 71 |
+
err = {}
|
| 72 |
+
if not isinstance(err, dict):
|
| 73 |
+
# e.g. slowapi's stock 429 body was {"error": "<string>"} — main.py
|
| 74 |
+
# now owns that envelope, but stay robust to any legacy shape.
|
| 75 |
+
err = {}
|
| 76 |
+
if err.get("message"):
|
| 77 |
+
return err["message"], str(err.get("code") or exc.response.status_code)
|
| 78 |
+
if exc.response.status_code == 429:
|
| 79 |
+
return (
|
| 80 |
+
"Too many requests — please wait a minute and try again.",
|
| 81 |
+
"429",
|
| 82 |
+
)
|
| 83 |
+
return (
|
| 84 |
+
"The server had a problem starting this job — please try again.",
|
| 85 |
+
str(exc.response.status_code),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _post_job(prompt: str, model: str, character: str, duration: float, seed: int,
|
| 90 |
+
headers: dict[str, str] | None = None) -> str:
|
| 91 |
+
"""POST /v1/jobs → job_id. Text-only convenience wrapper for the
|
| 92 |
+
legacy in-Space `_generate` handler. Browser callers use
|
| 93 |
+
`_generate_full` instead, which forwards the full GenerateRequest
|
| 94 |
+
body verbatim and so supports trajectory / waypoints / timeline."""
|
| 95 |
+
payload: dict[str, Any] = {
|
| 96 |
+
"input": {"type": "text", "prompt": prompt},
|
| 97 |
+
"model": model,
|
| 98 |
+
"character": character,
|
| 99 |
+
"duration": float(duration),
|
| 100 |
+
}
|
| 101 |
+
if seed >= 0:
|
| 102 |
+
payload["seed"] = int(seed)
|
| 103 |
+
return _post_job_body(payload, headers=headers)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _poll_job(job_id: str, max_wait: float = 180.0) -> dict:
|
| 107 |
+
"""Poll /v1/jobs/{id} until done | failed | cancelled, or timeout."""
|
| 108 |
+
deadline = time.time() + max_wait
|
| 109 |
+
last_stage = None
|
| 110 |
+
while time.time() < deadline:
|
| 111 |
+
r = httpx.get(
|
| 112 |
+
f"{_API_BASE}/v1/jobs/{job_id}",
|
| 113 |
+
headers={"Authorization": f"Bearer {_API_KEY}"},
|
| 114 |
+
timeout=15.0,
|
| 115 |
+
)
|
| 116 |
+
r.raise_for_status()
|
| 117 |
+
body = r.json()
|
| 118 |
+
if body.get("stage") and body["stage"] != last_stage:
|
| 119 |
+
last_stage = body["stage"]
|
| 120 |
+
log.info("[%s] %s", job_id, last_stage)
|
| 121 |
+
if body["status"] in ("done", "failed", "cancelled"):
|
| 122 |
+
return body
|
| 123 |
+
time.sleep(1.5)
|
| 124 |
+
raise TimeoutError(f"Job {job_id} did not finish in {max_wait}s")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _generate(
|
| 128 |
+
prompt: str,
|
| 129 |
+
model: str,
|
| 130 |
+
character: str,
|
| 131 |
+
duration: float,
|
| 132 |
+
seed: int,
|
| 133 |
+
progress: gr.Progress = gr.Progress(),
|
| 134 |
+
request: gr.Request | None = None,
|
| 135 |
+
) -> tuple[str, str, str]:
|
| 136 |
+
"""Submit → poll → return (path-to-glb-or-fbx, status text, rewritten_prompt)."""
|
| 137 |
+
if not prompt or not prompt.strip():
|
| 138 |
+
raise gr.Error("Prompt is required")
|
| 139 |
+
|
| 140 |
+
# In-Space (hf.space iframe) visitors carry an HF-injected X-IP-Token;
|
| 141 |
+
# forward it across the loopback so ZeroGPU attributes their quota
|
| 142 |
+
# (see _zerogpu_attribution in animoflow-api main.py).
|
| 143 |
+
_ip_token = request.headers.get("x-ip-token") if request is not None else None
|
| 144 |
+
_fwd_headers = {"x-ip-token": _ip_token} if _ip_token else None
|
| 145 |
+
|
| 146 |
+
progress(0.05, desc="Submitting…")
|
| 147 |
+
try:
|
| 148 |
+
job_id = _post_job(prompt, model, character, duration, seed,
|
| 149 |
+
headers=_fwd_headers)
|
| 150 |
+
except httpx.HTTPStatusError as exc:
|
| 151 |
+
# NB: the previous try/except here caught its own gr.Error, so the
|
| 152 |
+
# structured-message path never ran and everything collapsed to a
|
| 153 |
+
# raw "API error: {exc}" leak. _submit_error keeps parsing and
|
| 154 |
+
# raising separate.
|
| 155 |
+
message, _ = _submit_error(exc)
|
| 156 |
+
raise gr.Error(message) from exc
|
| 157 |
+
|
| 158 |
+
progress(0.10, desc="Generating…")
|
| 159 |
+
try:
|
| 160 |
+
job = _poll_job(job_id)
|
| 161 |
+
except TimeoutError as exc:
|
| 162 |
+
raise gr.Error(str(exc)) from exc
|
| 163 |
+
|
| 164 |
+
if job["status"] != "done":
|
| 165 |
+
# The backend's ``error`` field is the single user-facing string —
|
| 166 |
+
# populated by api.error_classify with a clean message. We render
|
| 167 |
+
# it verbatim; no client-side formatting or branching on
|
| 168 |
+
# error_info.code.
|
| 169 |
+
msg = job.get("error") or "Job did not complete."
|
| 170 |
+
raise gr.Error(msg)
|
| 171 |
+
|
| 172 |
+
download_url = job.get("download_url", "") # /v1/files/<job_id>.fbx
|
| 173 |
+
if not download_url:
|
| 174 |
+
raise gr.Error("Job done but no download_url returned")
|
| 175 |
+
|
| 176 |
+
# The file is on disk in OUTPUT_DIR — return a local path so the
|
| 177 |
+
# Model3D widget loads it without a second HTTP roundtrip.
|
| 178 |
+
filename = download_url.rsplit("/", 1)[-1]
|
| 179 |
+
fbx_path = _OUTPUT_DIR / filename
|
| 180 |
+
glb_path = fbx_path.with_suffix(".glb")
|
| 181 |
+
out_path = glb_path if glb_path.exists() else fbx_path
|
| 182 |
+
if not out_path.exists():
|
| 183 |
+
raise gr.Error(f"Output file missing: {out_path}")
|
| 184 |
+
|
| 185 |
+
progress(1.0, desc="Done")
|
| 186 |
+
summary = (
|
| 187 |
+
f"Job {job_id} · model={model} · character={character} · "
|
| 188 |
+
f"duration={duration}s · seed={seed}"
|
| 189 |
+
)
|
| 190 |
+
# Surface the rewritten prompt so the user can see what was actually fed
|
| 191 |
+
# to the motion model. Empty string when the rewriter was skipped or the
|
| 192 |
+
# heuristic short-circuited (input was already HumanML3D-style English).
|
| 193 |
+
rewritten = job.get("rewritten_prompt") or ""
|
| 194 |
+
original = job.get("original_prompt") or ""
|
| 195 |
+
if rewritten and rewritten.strip() != (original or "").strip():
|
| 196 |
+
rewritten_display = rewritten
|
| 197 |
+
else:
|
| 198 |
+
rewritten_display = ""
|
| 199 |
+
return str(out_path), summary, rewritten_display
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _generate_full(
|
| 203 |
+
request_json: str,
|
| 204 |
+
progress: gr.Progress = gr.Progress(),
|
| 205 |
+
request: gr.Request | None = None,
|
| 206 |
+
):
|
| 207 |
+
"""Submit → poll → yield (path, summary, rewritten) for any task type.
|
| 208 |
+
|
| 209 |
+
The browser-side `@gradio/client` calls this via
|
| 210 |
+
``Client.predict("/generate_full", request_json=...)`` with the
|
| 211 |
+
full ``GenerateRequest`` body JSON-serialized as a string. Same
|
| 212 |
+
body shape as ``POST /v1/jobs`` accepts — see animoflow-api's
|
| 213 |
+
GenerateRequest schema (text / trajectory / waypoints / timeline
|
| 214 |
+
discriminated union). Routing all task types through this single
|
| 215 |
+
Gradio endpoint is what lets ZeroGPU's ``x-ip-token`` middleware
|
| 216 |
+
attribute quota to the signed-in user's HF account.
|
| 217 |
+
|
| 218 |
+
**Generator pattern intentionally.** Each ``yield`` emits a
|
| 219 |
+
``process_generating`` SSE event over the simplified
|
| 220 |
+
``/gradio_api/call/generate_full/<event_id>`` endpoint that the
|
| 221 |
+
browser-direct custom client consumes (the simple SSE endpoint
|
| 222 |
+
DROPS gr.Progress events, only forwards generator yields). The
|
| 223 |
+
intermediate yields use ``gr.update()`` for the Model3D + rewritten
|
| 224 |
+
textbox so the Gradio UI doesn't flicker the viewer between stages.
|
| 225 |
+
Browser-direct callers read the status string from the second tuple
|
| 226 |
+
slot and ignore the first/third on intermediate yields. Final yield
|
| 227 |
+
has the actual file path + summary + rewritten_display.
|
| 228 |
+
|
| 229 |
+
Output components: (Model3D, status Textbox, rewritten Textbox) —
|
| 230 |
+
matches Blocks wiring at the bottom of this file.
|
| 231 |
+
"""
|
| 232 |
+
if not request_json or not request_json.strip():
|
| 233 |
+
raise gr.Error("request_json is required")
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
body = json.loads(request_json)
|
| 237 |
+
except json.JSONDecodeError as exc:
|
| 238 |
+
raise gr.Error(f"request_json is not valid JSON: {exc}") from exc
|
| 239 |
+
if not isinstance(body, dict):
|
| 240 |
+
raise gr.Error("request_json must be a JSON object")
|
| 241 |
+
|
| 242 |
+
# Progress encoding: the second-slot status string carries a trailing
|
| 243 |
+
# "(NN%)" that the browser-direct client (web/app/gradio-client-custom.js
|
| 244 |
+
# → app.js:_generateViaGradio) parses to drive the spinner row's
|
| 245 |
+
# --progress CSS variable. The 4th slot is RESERVED for snap_info JSON
|
| 246 |
+
# on the final yield (per commit d14ae86 — per-gen snap receipt) — we
|
| 247 |
+
# keep it as gr.update() on every intermediate yield so the hidden
|
| 248 |
+
# snap_info textbox isn't clobbered mid-run.
|
| 249 |
+
# The simple SSE endpoint we call from the browser DROPS gr.Progress
|
| 250 |
+
# events, so the only reliable carrier is the yielded tuple itself
|
| 251 |
+
# (encode progress into a tuple slot).
|
| 252 |
+
# Attribution for usage analytics: hash the browser-direct caller's
|
| 253 |
+
# bearer token so the internal loopback /v1/jobs hop keeps the real
|
| 254 |
+
# identity instead of "dev". Never carries raw token material.
|
| 255 |
+
_actor = None
|
| 256 |
+
if usage_log is not None and request is not None:
|
| 257 |
+
try:
|
| 258 |
+
_actor = usage_log.actor_from_authorization(
|
| 259 |
+
request.headers.get("authorization"))
|
| 260 |
+
if _actor is None:
|
| 261 |
+
# Anonymous browser-direct caller: forward a hashed-IP
|
| 262 |
+
# identity so the fair-use overflow cap can key on them.
|
| 263 |
+
# Without this, anon loopback jobs collapse into the
|
| 264 |
+
# internal API key's identity (gap found 2026-07-07).
|
| 265 |
+
_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
| 266 |
+
or (request.client.host if request.client else ""))
|
| 267 |
+
_actor = usage_log.actor_from_ip(_ip)
|
| 268 |
+
except Exception: # analytics must never break generation
|
| 269 |
+
_actor = None
|
| 270 |
+
_actor_headers = {usage_log.ACTOR_HEADER: _actor} if _actor else None
|
| 271 |
+
# ZeroGPU attribution: forward the caller's X-IP-Token JWT across the
|
| 272 |
+
# loopback hop. The spaces scheduler reads it from gradio's request
|
| 273 |
+
# context, which the internal /v1/jobs request severs — main.py
|
| 274 |
+
# replants it around the pipeline run (_zerogpu_attribution). Without
|
| 275 |
+
# this, every GPU call runs token-less on the shared pool.
|
| 276 |
+
_ip_token = request.headers.get("x-ip-token") if request is not None else None
|
| 277 |
+
if _ip_token:
|
| 278 |
+
_actor_headers = {**(_actor_headers or {}), "x-ip-token": _ip_token}
|
| 279 |
+
|
| 280 |
+
def _gradio_call_event(phase: str, **extra) -> None:
|
| 281 |
+
if usage_log is None:
|
| 282 |
+
return
|
| 283 |
+
kind, _, user = (_actor or "").partition(":")
|
| 284 |
+
usage_log.emit({
|
| 285 |
+
"event": "gradio_call", "phase": phase,
|
| 286 |
+
"user": user or "internal", "user_kind": kind or "internal",
|
| 287 |
+
"client": "browser-direct", **extra,
|
| 288 |
+
})
|
| 289 |
+
|
| 290 |
+
progress(0.05, desc="Submitting…")
|
| 291 |
+
yield gr.update(), "Submitting… (5%)", gr.update(), gr.update()
|
| 292 |
+
try:
|
| 293 |
+
job_id = _post_job_body(body, headers=_actor_headers)
|
| 294 |
+
except httpx.HTTPStatusError as exc:
|
| 295 |
+
message, code = _submit_error(exc)
|
| 296 |
+
_gradio_call_event(
|
| 297 |
+
"submit_rejected", job_id=None,
|
| 298 |
+
error_code=code,
|
| 299 |
+
error_message=message[:300],
|
| 300 |
+
)
|
| 301 |
+
raise gr.Error(message) from exc
|
| 302 |
+
|
| 303 |
+
_gradio_call_event("linked", job_id=job_id)
|
| 304 |
+
|
| 305 |
+
progress(0.10, desc="Generating…")
|
| 306 |
+
# Slot 3 carries the job_id early: gradio's simple /call SSE endpoint
|
| 307 |
+
# serializes error events as `data: null` (the gr.Error message is
|
| 308 |
+
# lost on the wire), so browser-direct callers need the job_id to
|
| 309 |
+
# fetch the real classified error via GET /v1/jobs/{id} when the
|
| 310 |
+
# stream errors out. The final yield reuses slot 3 for snap_info.
|
| 311 |
+
yield gr.update(), "Generating… (10%)", gr.update(), json.dumps({"job_id": job_id})
|
| 312 |
+
|
| 313 |
+
# Inline poll so we can yield each stage AND progress change.
|
| 314 |
+
# 0.4 s poll matches the legacy /v1/jobs poller in app.js — at 1.5 s
|
| 315 |
+
# short pipeline stages (e.g. IK, post-fix) flew past between polls
|
| 316 |
+
# and the second-slot yield stayed "Generating…" the whole run.
|
| 317 |
+
max_wait = 180.0
|
| 318 |
+
poll_interval = 0.4
|
| 319 |
+
deadline = time.time() + max_wait
|
| 320 |
+
last_stage = None
|
| 321 |
+
last_pct = -1
|
| 322 |
+
job = None
|
| 323 |
+
while time.time() < deadline:
|
| 324 |
+
try:
|
| 325 |
+
r = httpx.get(
|
| 326 |
+
f"{_API_BASE}/v1/jobs/{job_id}",
|
| 327 |
+
headers={"Authorization": f"Bearer {_API_KEY}"},
|
| 328 |
+
timeout=15.0,
|
| 329 |
+
)
|
| 330 |
+
r.raise_for_status()
|
| 331 |
+
body_resp = r.json()
|
| 332 |
+
except Exception as exc: # transient HTTP / parse error — keep polling
|
| 333 |
+
log.warning("[%s] poll error: %s", job_id, exc)
|
| 334 |
+
time.sleep(poll_interval)
|
| 335 |
+
continue
|
| 336 |
+
cur_stage = body_resp.get("stage") or last_stage or "Running"
|
| 337 |
+
cur_progress = max(0.0, min(1.0, float(body_resp.get("progress") or 0.0)))
|
| 338 |
+
cur_pct = int(round(cur_progress * 100))
|
| 339 |
+
if cur_stage != last_stage or cur_pct != last_pct:
|
| 340 |
+
if cur_stage != last_stage:
|
| 341 |
+
log.info("[%s] %s (%d%%)", job_id, cur_stage, cur_pct)
|
| 342 |
+
last_stage = cur_stage
|
| 343 |
+
last_pct = cur_pct
|
| 344 |
+
yield gr.update(), f"{cur_stage} ({cur_pct}%)", gr.update(), gr.update()
|
| 345 |
+
if body_resp["status"] in ("done", "failed", "cancelled"):
|
| 346 |
+
job = body_resp
|
| 347 |
+
break
|
| 348 |
+
time.sleep(poll_interval)
|
| 349 |
+
if job is None:
|
| 350 |
+
_gradio_call_event("poll_timeout", job_id=job_id,
|
| 351 |
+
error_code="gradio_poll_timeout",
|
| 352 |
+
error_message=f"no terminal status within {max_wait}s")
|
| 353 |
+
raise gr.Error(f"Job {job_id} did not finish in {max_wait}s")
|
| 354 |
+
|
| 355 |
+
if job["status"] != "done":
|
| 356 |
+
msg = job.get("error") or "Job did not complete."
|
| 357 |
+
raise gr.Error(msg)
|
| 358 |
+
|
| 359 |
+
download_url = job.get("download_url", "")
|
| 360 |
+
if not download_url:
|
| 361 |
+
raise gr.Error("Job done but no download_url returned")
|
| 362 |
+
|
| 363 |
+
filename = download_url.rsplit("/", 1)[-1]
|
| 364 |
+
fbx_path = _OUTPUT_DIR / filename
|
| 365 |
+
glb_path = fbx_path.with_suffix(".glb")
|
| 366 |
+
out_path = glb_path if glb_path.exists() else fbx_path
|
| 367 |
+
if not out_path.exists():
|
| 368 |
+
raise gr.Error(f"Output file missing: {out_path}")
|
| 369 |
+
|
| 370 |
+
progress(1.0, desc="Done")
|
| 371 |
+
|
| 372 |
+
input_type = (body.get("input") or {}).get("type", "?")
|
| 373 |
+
model_name = body.get("model", "?")
|
| 374 |
+
duration_val = body.get("duration", "?")
|
| 375 |
+
summary = (
|
| 376 |
+
f"Job {job_id} · task={input_type} · model={model_name} · duration={duration_val}s"
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
rewritten = job.get("rewritten_prompt") or ""
|
| 380 |
+
original = job.get("original_prompt") or ""
|
| 381 |
+
if rewritten and rewritten.strip() != (original or "").strip():
|
| 382 |
+
rewritten_display = rewritten
|
| 383 |
+
else:
|
| 384 |
+
rewritten_display = ""
|
| 385 |
+
# JSON-serialise snap_info into a hidden textbox slot. Browser-direct
|
| 386 |
+
# callers (the AnimoFlow web app) read data[3]
|
| 387 |
+
# and surface it in the debug-response panel — same per-gen "did the
|
| 388 |
+
# snap run?" receipt that REST clients get via JobResponse.snap_info.
|
| 389 |
+
snap_info_json = json.dumps(job.get("snap_info"))
|
| 390 |
+
yield str(out_path), summary, rewritten_display, snap_info_json
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _list_characters() -> list[str]:
|
| 394 |
+
"""Best-effort character list from /v1/characters; fall back to filesystem."""
|
| 395 |
+
try:
|
| 396 |
+
r = httpx.get(
|
| 397 |
+
f"{_API_BASE}/v1/characters",
|
| 398 |
+
headers={"Authorization": f"Bearer {_API_KEY}"},
|
| 399 |
+
timeout=5.0,
|
| 400 |
+
)
|
| 401 |
+
r.raise_for_status()
|
| 402 |
+
names = r.json().get("characters", [])
|
| 403 |
+
if names:
|
| 404 |
+
return names
|
| 405 |
+
except Exception: # noqa: BLE001 — best effort during boot
|
| 406 |
+
pass
|
| 407 |
+
chars_dir = Path(
|
| 408 |
+
os.environ.get("CHARACTERS_DIR", "/opt/comfyui-animoflow/characters")
|
| 409 |
+
)
|
| 410 |
+
if chars_dir.is_dir():
|
| 411 |
+
return sorted(p.stem for p in chars_dir.glob("*.fbx"))
|
| 412 |
+
return ["Y_bot"]
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def build_blocks() -> gr.Blocks:
|
| 416 |
+
"""Construct the Gradio Blocks app. Called once at orchestrator startup."""
|
| 417 |
+
# Pull the character list once at boot. Refreshed on demand via the
|
| 418 |
+
# "↻" button next to the dropdown.
|
| 419 |
+
initial_chars = _list_characters() or ["Y_bot"]
|
| 420 |
+
|
| 421 |
+
with gr.Blocks(title="AnimoFlow Demo", theme=gr.themes.Soft()) as blocks:
|
| 422 |
+
gr.Markdown(
|
| 423 |
+
"""
|
| 424 |
+
# AnimoFlow — Text → Motion → FBX
|
| 425 |
+
|
| 426 |
+
Type a description, pick a character, hit **Generate**.
|
| 427 |
+
Powered by [MDM](https://guytevet.github.io/mdm-page/) +
|
| 428 |
+
momask Joint2BVH IK + Blender retarget.
|
| 429 |
+
|
| 430 |
+
Same `/v1/jobs` API as the local OSS stack — you can also call
|
| 431 |
+
it from `gradio_client`, `@gradio/client` JS, or curl.
|
| 432 |
+
"""
|
| 433 |
+
)
|
| 434 |
+
with gr.Row():
|
| 435 |
+
with gr.Column(scale=1):
|
| 436 |
+
prompt = gr.Textbox(
|
| 437 |
+
label="Prompt",
|
| 438 |
+
placeholder="a person walks forward and waves",
|
| 439 |
+
lines=3,
|
| 440 |
+
)
|
| 441 |
+
model = gr.Dropdown(
|
| 442 |
+
choices=["mdm", "momask", "kimodo"],
|
| 443 |
+
value="mdm",
|
| 444 |
+
label="Model",
|
| 445 |
+
)
|
| 446 |
+
character = gr.Dropdown(
|
| 447 |
+
choices=initial_chars,
|
| 448 |
+
value=initial_chars[0],
|
| 449 |
+
label="Character",
|
| 450 |
+
)
|
| 451 |
+
duration = gr.Slider(
|
| 452 |
+
minimum=1.0,
|
| 453 |
+
maximum=10.0,
|
| 454 |
+
value=4.0,
|
| 455 |
+
step=0.5,
|
| 456 |
+
label="Duration (seconds)",
|
| 457 |
+
)
|
| 458 |
+
seed = gr.Number(
|
| 459 |
+
value=-1, label="Seed (-1 = random)", precision=0
|
| 460 |
+
)
|
| 461 |
+
submit = gr.Button("Generate", variant="primary")
|
| 462 |
+
with gr.Column(scale=2):
|
| 463 |
+
viewer = gr.Model3D(
|
| 464 |
+
label="Preview", display_mode="solid", clear_color=[0, 0, 0, 0]
|
| 465 |
+
)
|
| 466 |
+
status = gr.Textbox(label="Job", interactive=False, lines=2)
|
| 467 |
+
# Surfaces the rewritten prompt when the multilingual rewriter
|
| 468 |
+
# actually fires (non-empty + different from the original). The
|
| 469 |
+
# textbox stays blank for English-HumanML3D-style inputs that
|
| 470 |
+
# the cheap skip heuristic short-circuits. Original/rewritten
|
| 471 |
+
# are also in /v1/jobs/{id} for API consumers.
|
| 472 |
+
rewritten_box = gr.Textbox(
|
| 473 |
+
label="Rewritten as (what the model actually saw)",
|
| 474 |
+
interactive=False,
|
| 475 |
+
lines=2,
|
| 476 |
+
placeholder="(your prompt was kept as-is)",
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
submit.click(
|
| 480 |
+
fn=_generate,
|
| 481 |
+
inputs=[prompt, model, character, duration, seed],
|
| 482 |
+
outputs=[viewer, status, rewritten_box],
|
| 483 |
+
# Stable named endpoint so the browser-side @gradio/client
|
| 484 |
+
# in animoflow-api can call Client.predict("/generate", ...).
|
| 485 |
+
api_name="generate",
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
# Hidden API-only endpoint that accepts the full GenerateRequest
|
| 489 |
+
# body as a JSON string. Used by animoflow-api's browser-side
|
| 490 |
+
# @gradio/client for all four task types (text / trajectory /
|
| 491 |
+
# waypoints / timeline). Not visible in the Space's own UI —
|
| 492 |
+
# external callers reach it via Client.predict("/generate_full", ...).
|
| 493 |
+
request_json_in = gr.Textbox(visible=False)
|
| 494 |
+
snap_info_box = gr.Textbox(visible=False)
|
| 495 |
+
submit_full = gr.Button(visible=False)
|
| 496 |
+
submit_full.click(
|
| 497 |
+
fn=_generate_full,
|
| 498 |
+
inputs=[request_json_in],
|
| 499 |
+
outputs=[viewer, status, rewritten_box, snap_info_box],
|
| 500 |
+
api_name="generate_full",
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
gr.Markdown(
|
| 504 |
+
"""
|
| 505 |
+
### API access
|
| 506 |
+
|
| 507 |
+
```bash
|
| 508 |
+
curl -X POST $API/v1/jobs \\
|
| 509 |
+
-H "Authorization: Bearer dev" \\
|
| 510 |
+
-H "Content-Type: application/json" \\
|
| 511 |
+
-d '{"input":{"type":"text","prompt":"a person walks"},"model":"mdm","duration":4}'
|
| 512 |
+
```
|
| 513 |
+
|
| 514 |
+
Then poll `/v1/jobs/{id}` until `status=done` and download
|
| 515 |
+
`/v1/files/{id}.fbx` (or `.glb`).
|
| 516 |
+
"""
|
| 517 |
+
)
|
| 518 |
+
return blocks
|