AnimoFlow commited on
Commit
555715e
·
0 Parent(s):

AnimoFlow hosted-demo wrapper — initial public release (v0.1.0-beta)

Browse files
.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