animoflow-demo / app.py
AnimoFlow's picture
AnimoFlow demo β€” public release baseline
a5faf98
Raw
History Blame Contribute Delete
12.3 kB
"""
animoflow-app β€” orchestrator entry point.
Mounts a Gradio UI on top of the imported animoflow-api FastAPI app, then
monkeypatches animoflow-api's `pipeline.run` / `pipeline.run_timeline` with
our HF-flavored pipeline that calls the GPU-decorated inference function
in-process and runs all post-processing CPU-side in the orchestrator.
NOTHING in animoflow-api/api/main.py or animoflow-api/api/pipeline.py is
modified on disk. The wrap-don't-modify rule extends transitively to our
own repos here.
Order of operations matters:
1. sys.path inject /opt/animoflow-api/api so its sibling-module imports
(`import config`, `import job_store`, `from auth import …`) resolve.
2. Import the `pipeline` module FROM that path.
3. Replace pipeline.run / pipeline.run_timeline with our pipeline_hf
versions. Functions are looked up at call time inside
animoflow-api/api/main.py:_run_pipeline, so monkeypatching after import
is enough.
4. Import animoflow-api's `main` (which pulls in pipeline by name).
5. Build Gradio Blocks and mount them on the FastAPI app at "/".
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
log = logging.getLogger("animoflow-app")
# ---------------------------------------------------------------------------
# Step -2: ensure OUTPUT_DIR is consistent across all modules.
#
# pipeline_hf defaults to /tmp/animoflow-output; animoflow-api/api/config.py
# defaults to a home-directory path. On HF Spaces (Gradio SDK, no Dockerfile ENV),
# the env var may not be set, so the two defaults diverge and /v1/files/
# returns 404 (config.OUTPUT_DIR points at a dir the pipeline never wrote to).
# Fix: plant the env var before any animoflow-api import.
# ---------------------------------------------------------------------------
os.environ.setdefault("OUTPUT_DIR", "/tmp/animoflow-output")
# HF Space's xet protocol fails with "Permission denied (os error 13)" trying
# to write its cache logs to /home/user/.cache/huggingface/xet/. Bootstrapping
# huggingface_hub via the legacy LFS-style download avoids the issue. Surfaced
# when sentence-transformers landed in requirements.txt and pip resolved
# transformers up to 5.12 which uses xet by default.
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
# ---------------------------------------------------------------------------
# Step -1: bootstrap external repos + checkpoints when running on HF Gradio
# Spaces (which have no Docker build phase). No-op in Docker mode.
# ---------------------------------------------------------------------------
# Make sure our own dir is on sys.path so `import bootstrap` works regardless
# of how the process was launched.
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
import bootstrap as _bootstrap # noqa: E402
_bootstrap.bootstrap_external_repos()
# ---------------------------------------------------------------------------
# Step 0: auto-detect Blender for the rig + GLB stages.
# `MotionRetargeter.bvh_to_fbx` reads $BLENDER_BIN at module load. If it's
# not set (e.g. a fresh OSS-local Mac dev), we plant the right value here
# before any retargeter import. The pipeline_hf._find_blender() helper
# uses the same priority order.
# ---------------------------------------------------------------------------
import shutil as _shutil
from pathlib import Path as _Path
if not os.environ.get("BLENDER_BIN", "").strip():
for _candidate in (
_shutil.which("blender"),
"/Applications/Blender.app/Contents/MacOS/Blender",
"/opt/blender/blender",
):
if _candidate and _Path(_candidate).is_file():
os.environ["BLENDER_BIN"] = _candidate
log.info("Auto-detected Blender at %s", _candidate)
break
else:
log.warning(
"Blender not found on PATH or at common install locations. "
"Rig + GLB stages will fail. Set BLENDER_BIN to override."
)
# ---------------------------------------------------------------------------
# Step 1: locate animoflow-api and put its api/ on sys.path
# ---------------------------------------------------------------------------
_ANIMOFLOW_API_API = os.environ.get(
"ANIMOFLOW_API_API_DIR", "/opt/animoflow-api/api"
)
if not Path(_ANIMOFLOW_API_API).is_dir():
raise RuntimeError(
f"animoflow-api/api not found at {_ANIMOFLOW_API_API!r}. "
"Set ANIMOFLOW_API_API_DIR or rebuild the Docker image. "
"The wrapper repo must be cloned at Docker build time."
)
if _ANIMOFLOW_API_API not in sys.path:
sys.path.insert(0, _ANIMOFLOW_API_API)
# ---------------------------------------------------------------------------
# Step 2: locate animoflow-app's own code on sys.path so pipeline_hf etc.
# import cleanly when uvicorn is invoked from another cwd.
# ---------------------------------------------------------------------------
_ANIMOFLOW_APP = Path(__file__).resolve().parent
if str(_ANIMOFLOW_APP) not in sys.path:
sys.path.insert(0, str(_ANIMOFLOW_APP))
# ---------------------------------------------------------------------------
# Step 3: import animoflow-api's `pipeline` and replace its public functions
# ---------------------------------------------------------------------------
import pipeline as _animoflow_api_pipeline # noqa: E402 (animoflow-api's module)
import pipeline_hf # noqa: E402 (ours, replaces the ComfyUI-orchestrated path)
_animoflow_api_pipeline.run = pipeline_hf.run
_animoflow_api_pipeline.run_timeline = pipeline_hf.run_timeline
log.info("Monkeypatched animoflow-api pipeline.run / run_timeline β†’ pipeline_hf")
# ---------------------------------------------------------------------------
# Step 4: import the FastAPI app from animoflow-api/api/main.py
# ---------------------------------------------------------------------------
from main import app as fastapi_app # noqa: E402
log.info("Imported animoflow-api FastAPI app (title=%r)", fastapi_app.title)
# ---------------------------------------------------------------------------
# Step 4b: warm up the multilingual prompt rewriter in the parent process so
# the @spaces.GPU fork inherits the loaded Qwen + MiniLM + corpus via COW on
# every subsequent call. Without this, the very first GPU rewrite has to
# pay the model-load + CUDA-move cost INSIDE the @GPU budget, and gets killed
# by ZeroGPU with 'GPU task aborted' once it exceeds the duration.
# Same eager-load pattern as the MDM registry (animoflow_models/registry.py).
# ---------------------------------------------------------------------------
try:
import rewriter as _rewriter # noqa: E402 (animoflow-api's module)
_rewriter.warmup()
log.info("Rewriter warmed up in orchestrator process")
except ImportError:
log.warning("Rewriter module not on sys.path β€” skipping warmup")
except Exception as e:
log.warning("Rewriter warmup raised β€” first /v1/jobs request will retry: %s", e)
# ---------------------------------------------------------------------------
# Step 5: build the Gradio Blocks UI and mount on "/"
# ---------------------------------------------------------------------------
import gradio as gr # noqa: E402
from ui import build_blocks # noqa: E402
_blocks = build_blocks()
# Kimodo health surface. animoflow-api's _MODEL_HEALTH_URLS polls
# ${KIMODO_ENDPOINT}/health every HEALTH_POLL_INTERVAL seconds. Default
# KIMODO_ENDPOINT in animoflow-api/api/config.py points at localhost:8005
# (the local-stack Docker container), which is unreachable inside the HF
# Space. bootstrap._set_env_for_kimodo plants KIMODO_ENDPOINT pointing at
# this in-process route β€” Kimodo surfaces as available in /v1/models (and
# the webUI dropdown) once the venv build finishes.
@fastapi_app.get("/__internal/kimodo/health")
def _kimodo_internal_health(): # noqa: D401
from fastapi.responses import JSONResponse
# escape_hatch/__init__.py does `from .invoke import invoke` which
# overwrites the submodule reference with the function. Even
# `import escape_hatch.invoke as X` then binds X to the function. Pull
# the module straight from sys.modules to bypass the shadowing.
import escape_hatch # noqa: F401 β€” ensure escape_hatch.invoke is in sys.modules
import sys as _sys
_ehi = _sys.modules["escape_hatch.invoke"]
if os.environ.get("ENABLE_KIMODO", "true").strip().lower() == "false":
return JSONResponse(status_code=503, content={"status": "disabled"})
failed = _ehi._kimodo_failed_message()
if failed:
return JSONResponse(
status_code=503,
content={"status": "failed", "error": failed[:400]},
)
event = _ehi._KIMODO_READY
ready_sentinel = _ehi._external_dir() / ".kimodo_ready"
ready = ready_sentinel.is_file() or (event is not None and event.is_set())
if ready:
return {"status": "ok"}
return JSONResponse(status_code=503, content={"status": "warming"})
# `gr.mount_gradio_app` registers Gradio's routes on the FastAPI app.
# WEB_DIR=/nonexistent in our Dockerfile makes animoflow-api skip its
# StaticFiles("/") catch-all, so "/" is free for Gradio to claim.
_output_dir = os.environ.get("OUTPUT_DIR", "/tmp/animoflow-output")
fastapi_app = gr.mount_gradio_app(
fastapi_app, _blocks, path="/",
allowed_paths=[_output_dir],
)
log.info("Mounted Gradio UI at / (allowed_paths=%s)", [_output_dir])
# HF Gradio SDK auto-detection: expose `demo` at module level
demo = _blocks
def main() -> None:
"""Local-dev convenience entry: `python app.py` boots uvicorn."""
import uvicorn
uvicorn.run(
"app:fastapi_app",
host="0.0.0.0",
port=int(os.environ.get("PORT", "7860")),
log_level=os.environ.get("LOG_LEVEL", "info").lower(),
)
if __name__ == "__main__":
if os.environ.get("SPACE_ID"):
# On HF Gradio SDK β€” ZeroGPU requires demo.launch() for GPU
# registration. We monkey-patch Gradio's app factory to inject
# our /v1/* FastAPI routes into the Gradio-managed app.
import gradio.routes as _gr_routes
_orig_create = _gr_routes.App.create_app
def _patched_create(blocks, **kw):
gapp = _orig_create(blocks, **kw)
# Inject animoflow-api's API routes. PREPEND, don't append β€”
# Starlette matches routes in registration order and Gradio
# ships its own `/openapi.json`, `/docs`, `/redoc` that would
# otherwise win and serve Gradio's internal spec instead of
# animoflow-api's branded one (title: "AnimoFlow API",
# 10 documented /v1/* routes). Inserting at index 0 in
# reverse iteration order preserves animoflow-api's own
# ordering. The /v1/* + /oauth/* routes were already unique
# to animoflow-api so prepending doesn't change their
# behavior β€” only the spec-surface routes flip to ours.
for route in reversed(list(fastapi_app.routes)):
gapp.routes.insert(0, route)
# Copy middleware
for mw in fastapi_app.user_middleware:
gapp.add_middleware(mw.cls, **mw.kwargs)
# Copy exception handlers
for exc_cls, handler in fastapi_app.exception_handlers.items():
gapp.add_exception_handler(exc_cls, handler)
# slowapi's RateLimitExceeded handler reads
# request.app.state.limiter β€” and request.app here is THIS
# Gradio app, not animoflow-api's. Without this line every
# rate-limit hit 500s with AttributeError instead of a 429
# (found 2026-07-06 by the quota probe's parallel round).
gapp.state.limiter = fastapi_app.state.limiter
log.info("Injected animoflow-api routes into Gradio app (prepended for /openapi.json + /redoc + /docs precedence)")
return gapp
_gr_routes.App.create_app = _patched_create
demo.launch(allowed_paths=[_output_dir])
else:
main()