Spaces:
Running on Zero
Running on Zero
| """ | |
| 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. | |
| 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() | |