Spaces:
Running on Zero
Running on Zero
| """ | |
| Gradio Blocks UI for the AnimoFlow HF Space. | |
| The UI is a thin client over the FastAPI /v1/jobs endpoint that the | |
| animoflow-api app already implements. We POST a job, poll for status, | |
| then download the resulting FBX/GLB. | |
| Same surface that the animoflow.ai webpage and Blender add-on consume. | |
| The UI itself uses no privileged path β anything you can do here, you | |
| can do via curl. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| import gradio as gr | |
| import httpx | |
| log = logging.getLogger(__name__) | |
| # Usage analytics (animoflow-api module; on sys.path via app.py step 1). | |
| # No-op unless USAGE_LOG_* env vars are set β see usage_log.py docstring. | |
| try: | |
| import usage_log | |
| except ImportError: # bare ui.py import outside the Space wiring (tests) | |
| usage_log = None | |
| log.warning("usage_log not importable β usage analytics disabled") | |
| # Where animoflow-api's /v1 lives. In-process the FastAPI app is mounted at | |
| # the same root, so http://localhost:<port> works. | |
| _PORT = int(os.environ.get("PORT", "7860")) | |
| _API_BASE = os.environ.get("ANIMOFLOW_API_BASE", f"http://127.0.0.1:{_PORT}") | |
| _API_KEY = os.environ.get("ANIMOFLOW_API_KEY", "dev") | |
| _OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/tmp/animoflow-output")) | |
| def _post_job_body(body: dict[str, Any], headers: dict[str, str] | None = None) -> str: | |
| """POST /v1/jobs with a pre-built body β job_id. Raises on non-2xx. | |
| ``headers`` lets callers add attribution (x-usage-actor) on the internal | |
| loopback hop without touching the auth header. | |
| """ | |
| r = httpx.post( | |
| f"{_API_BASE}/v1/jobs", | |
| json=body, | |
| headers={"Authorization": f"Bearer {_API_KEY}", **(headers or {})}, | |
| timeout=15.0, | |
| ) | |
| r.raise_for_status() | |
| return r.json()["job_id"] | |
| def _submit_error(exc: httpx.HTTPStatusError) -> tuple[str, str]: | |
| """(user_message, error_code) for a non-2xx POST /v1/jobs. | |
| Prefer the API's own classified message (error_classify populates it on | |
| clean failures); otherwise fall back to a neutral per-status message. | |
| Never interpolate the raw exception β it carries internal URLs | |
| (e.g. http://127.0.0.1:7860/v1/jobs) that must not reach users. | |
| """ | |
| try: | |
| err = exc.response.json().get("error", {}) | |
| except Exception: # noqa: BLE001 β non-JSON body (e.g. bare 500 page) | |
| err = {} | |
| if not isinstance(err, dict): | |
| # e.g. slowapi's stock 429 body was {"error": "<string>"} β main.py | |
| # now owns that envelope, but stay robust to any legacy shape. | |
| err = {} | |
| if err.get("message"): | |
| return err["message"], str(err.get("code") or exc.response.status_code) | |
| if exc.response.status_code == 429: | |
| return ( | |
| "Too many requests β please wait a minute and try again.", | |
| "429", | |
| ) | |
| return ( | |
| "The server had a problem starting this job β please try again.", | |
| str(exc.response.status_code), | |
| ) | |
| def _post_job(prompt: str, model: str, character: str, duration: float, seed: int, | |
| headers: dict[str, str] | None = None) -> str: | |
| """POST /v1/jobs β job_id. Text-only convenience wrapper for the | |
| legacy in-Space `_generate` handler. Browser callers use | |
| `_generate_full` instead, which forwards the full GenerateRequest | |
| body verbatim and so supports trajectory / waypoints / timeline.""" | |
| payload: dict[str, Any] = { | |
| "input": {"type": "text", "prompt": prompt}, | |
| "model": model, | |
| "character": character, | |
| "duration": float(duration), | |
| } | |
| if seed >= 0: | |
| payload["seed"] = int(seed) | |
| return _post_job_body(payload, headers=headers) | |
| def _poll_job(job_id: str, max_wait: float = 180.0) -> dict: | |
| """Poll /v1/jobs/{id} until done | failed | cancelled, or timeout.""" | |
| deadline = time.time() + max_wait | |
| last_stage = None | |
| while time.time() < deadline: | |
| r = httpx.get( | |
| f"{_API_BASE}/v1/jobs/{job_id}", | |
| headers={"Authorization": f"Bearer {_API_KEY}"}, | |
| timeout=15.0, | |
| ) | |
| r.raise_for_status() | |
| body = r.json() | |
| if body.get("stage") and body["stage"] != last_stage: | |
| last_stage = body["stage"] | |
| log.info("[%s] %s", job_id, last_stage) | |
| if body["status"] in ("done", "failed", "cancelled"): | |
| return body | |
| time.sleep(1.5) | |
| raise TimeoutError(f"Job {job_id} did not finish in {max_wait}s") | |
| def _generate( | |
| prompt: str, | |
| model: str, | |
| character: str, | |
| duration: float, | |
| seed: int, | |
| progress: gr.Progress = gr.Progress(), | |
| request: gr.Request | None = None, | |
| ) -> tuple[str, str, str]: | |
| """Submit β poll β return (path-to-glb-or-fbx, status text, rewritten_prompt).""" | |
| if not prompt or not prompt.strip(): | |
| raise gr.Error("Prompt is required") | |
| # In-Space (hf.space iframe) visitors carry an HF-injected X-IP-Token; | |
| # forward it across the loopback so ZeroGPU attributes their quota | |
| # (see _zerogpu_attribution in animoflow-api main.py). | |
| _ip_token = request.headers.get("x-ip-token") if request is not None else None | |
| _fwd_headers = {"x-ip-token": _ip_token} if _ip_token else None | |
| progress(0.05, desc="Submittingβ¦") | |
| try: | |
| job_id = _post_job(prompt, model, character, duration, seed, | |
| headers=_fwd_headers) | |
| except httpx.HTTPStatusError as exc: | |
| # NB: the previous try/except here caught its own gr.Error, so the | |
| # structured-message path never ran and everything collapsed to a | |
| # raw "API error: {exc}" leak. _submit_error keeps parsing and | |
| # raising separate. | |
| message, _ = _submit_error(exc) | |
| raise gr.Error(message) from exc | |
| progress(0.10, desc="Generatingβ¦") | |
| try: | |
| job = _poll_job(job_id) | |
| except TimeoutError as exc: | |
| raise gr.Error(str(exc)) from exc | |
| if job["status"] != "done": | |
| # The backend's ``error`` field is the single user-facing string β | |
| # populated by api.error_classify with a clean message. We render | |
| # it verbatim; no client-side formatting or branching on | |
| # error_info.code. | |
| msg = job.get("error") or "Job did not complete." | |
| raise gr.Error(msg) | |
| download_url = job.get("download_url", "") # /v1/files/<job_id>.fbx | |
| if not download_url: | |
| raise gr.Error("Job done but no download_url returned") | |
| # The file is on disk in OUTPUT_DIR β return a local path so the | |
| # Model3D widget loads it without a second HTTP roundtrip. | |
| filename = download_url.rsplit("/", 1)[-1] | |
| fbx_path = _OUTPUT_DIR / filename | |
| glb_path = fbx_path.with_suffix(".glb") | |
| out_path = glb_path if glb_path.exists() else fbx_path | |
| if not out_path.exists(): | |
| raise gr.Error(f"Output file missing: {out_path}") | |
| progress(1.0, desc="Done") | |
| summary = ( | |
| f"Job {job_id} Β· model={model} Β· character={character} Β· " | |
| f"duration={duration}s Β· seed={seed}" | |
| ) | |
| # Surface the rewritten prompt so the user can see what was actually fed | |
| # to the motion model. Empty string when the rewriter was skipped or the | |
| # heuristic short-circuited (input was already HumanML3D-style English). | |
| rewritten = job.get("rewritten_prompt") or "" | |
| original = job.get("original_prompt") or "" | |
| if rewritten and rewritten.strip() != (original or "").strip(): | |
| rewritten_display = rewritten | |
| else: | |
| rewritten_display = "" | |
| return str(out_path), summary, rewritten_display | |
| def _generate_full( | |
| request_json: str, | |
| progress: gr.Progress = gr.Progress(), | |
| request: gr.Request | None = None, | |
| ): | |
| """Submit β poll β yield (path, summary, rewritten) for any task type. | |
| The browser-side `@gradio/client` calls this via | |
| ``Client.predict("/generate_full", request_json=...)`` with the | |
| full ``GenerateRequest`` body JSON-serialized as a string. Same | |
| body shape as ``POST /v1/jobs`` accepts β see animoflow-api's | |
| GenerateRequest schema (text / trajectory / waypoints / timeline | |
| discriminated union). Routing all task types through this single | |
| Gradio endpoint is what lets ZeroGPU's ``x-ip-token`` middleware | |
| attribute quota to the signed-in user's HF account. | |
| **Generator pattern intentionally.** Each ``yield`` emits a | |
| ``process_generating`` SSE event over the simplified | |
| ``/gradio_api/call/generate_full/<event_id>`` endpoint that the | |
| browser-direct custom client consumes (the simple SSE endpoint | |
| DROPS gr.Progress events, only forwards generator yields). The | |
| intermediate yields use ``gr.update()`` for the Model3D + rewritten | |
| textbox so the Gradio UI doesn't flicker the viewer between stages. | |
| Browser-direct callers read the status string from the second tuple | |
| slot and ignore the first/third on intermediate yields. Final yield | |
| has the actual file path + summary + rewritten_display. | |
| Output components: (Model3D, status Textbox, rewritten Textbox) β | |
| matches Blocks wiring at the bottom of this file. | |
| """ | |
| if not request_json or not request_json.strip(): | |
| raise gr.Error("request_json is required") | |
| try: | |
| body = json.loads(request_json) | |
| except json.JSONDecodeError as exc: | |
| raise gr.Error(f"request_json is not valid JSON: {exc}") from exc | |
| if not isinstance(body, dict): | |
| raise gr.Error("request_json must be a JSON object") | |
| # Progress encoding: the second-slot status string carries a trailing | |
| # "(NN%)" that the browser-direct client (web/app/gradio-client-custom.js | |
| # β app.js:_generateViaGradio) parses to drive the spinner row's | |
| # --progress CSS variable. The 4th slot is RESERVED for snap_info JSON | |
| # on the final yield (per commit d14ae86 β per-gen snap receipt) β we | |
| # keep it as gr.update() on every intermediate yield so the hidden | |
| # snap_info textbox isn't clobbered mid-run. | |
| # The simple SSE endpoint we call from the browser DROPS gr.Progress | |
| # events, so the only reliable carrier is the yielded tuple itself | |
| # (encode progress into a tuple slot). | |
| # Attribution for usage analytics: hash the browser-direct caller's | |
| # bearer token so the internal loopback /v1/jobs hop keeps the real | |
| # identity instead of "dev". Never carries raw token material. | |
| _actor = None | |
| if usage_log is not None and request is not None: | |
| try: | |
| _actor = usage_log.actor_from_authorization( | |
| request.headers.get("authorization")) | |
| if _actor is None: | |
| # Anonymous browser-direct caller: forward a hashed-IP | |
| # identity so the fair-use overflow cap can key on them. | |
| # Without this, anon loopback jobs collapse into the | |
| # internal API key's identity (gap found 2026-07-07). | |
| _ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip() | |
| or (request.client.host if request.client else "")) | |
| _actor = usage_log.actor_from_ip(_ip) | |
| except Exception: # analytics must never break generation | |
| _actor = None | |
| _actor_headers = {usage_log.ACTOR_HEADER: _actor} if _actor else None | |
| # ZeroGPU attribution: forward the caller's X-IP-Token JWT across the | |
| # loopback hop. The spaces scheduler reads it from gradio's request | |
| # context, which the internal /v1/jobs request severs β main.py | |
| # replants it around the pipeline run (_zerogpu_attribution). Without | |
| # this, every GPU call runs token-less on the shared pool. | |
| _ip_token = request.headers.get("x-ip-token") if request is not None else None | |
| if _ip_token: | |
| _actor_headers = {**(_actor_headers or {}), "x-ip-token": _ip_token} | |
| def _gradio_call_event(phase: str, **extra) -> None: | |
| if usage_log is None: | |
| return | |
| kind, _, user = (_actor or "").partition(":") | |
| usage_log.emit({ | |
| "event": "gradio_call", "phase": phase, | |
| "user": user or "internal", "user_kind": kind or "internal", | |
| "client": "browser-direct", **extra, | |
| }) | |
| progress(0.05, desc="Submittingβ¦") | |
| yield gr.update(), "Submitting⦠(5%)", gr.update(), gr.update() | |
| try: | |
| job_id = _post_job_body(body, headers=_actor_headers) | |
| except httpx.HTTPStatusError as exc: | |
| message, code = _submit_error(exc) | |
| _gradio_call_event( | |
| "submit_rejected", job_id=None, | |
| error_code=code, | |
| error_message=message[:300], | |
| ) | |
| raise gr.Error(message) from exc | |
| _gradio_call_event("linked", job_id=job_id) | |
| progress(0.10, desc="Generatingβ¦") | |
| # Slot 3 carries the job_id early: gradio's simple /call SSE endpoint | |
| # serializes error events as `data: null` (the gr.Error message is | |
| # lost on the wire), so browser-direct callers need the job_id to | |
| # fetch the real classified error via GET /v1/jobs/{id} when the | |
| # stream errors out. The final yield reuses slot 3 for snap_info. | |
| yield gr.update(), "Generating⦠(10%)", gr.update(), json.dumps({"job_id": job_id}) | |
| # Inline poll so we can yield each stage AND progress change. | |
| # 0.4 s poll matches the legacy /v1/jobs poller in app.js β at 1.5 s | |
| # short pipeline stages (e.g. IK, post-fix) flew past between polls | |
| # and the second-slot yield stayed "Generatingβ¦" the whole run. | |
| max_wait = 180.0 | |
| poll_interval = 0.4 | |
| deadline = time.time() + max_wait | |
| last_stage = None | |
| last_pct = -1 | |
| job = None | |
| while time.time() < deadline: | |
| try: | |
| r = httpx.get( | |
| f"{_API_BASE}/v1/jobs/{job_id}", | |
| headers={"Authorization": f"Bearer {_API_KEY}"}, | |
| timeout=15.0, | |
| ) | |
| r.raise_for_status() | |
| body_resp = r.json() | |
| except Exception as exc: # transient HTTP / parse error β keep polling | |
| log.warning("[%s] poll error: %s", job_id, exc) | |
| time.sleep(poll_interval) | |
| continue | |
| cur_stage = body_resp.get("stage") or last_stage or "Running" | |
| cur_progress = max(0.0, min(1.0, float(body_resp.get("progress") or 0.0))) | |
| cur_pct = int(round(cur_progress * 100)) | |
| if cur_stage != last_stage or cur_pct != last_pct: | |
| if cur_stage != last_stage: | |
| log.info("[%s] %s (%d%%)", job_id, cur_stage, cur_pct) | |
| last_stage = cur_stage | |
| last_pct = cur_pct | |
| yield gr.update(), f"{cur_stage} ({cur_pct}%)", gr.update(), gr.update() | |
| if body_resp["status"] in ("done", "failed", "cancelled"): | |
| job = body_resp | |
| break | |
| time.sleep(poll_interval) | |
| if job is None: | |
| _gradio_call_event("poll_timeout", job_id=job_id, | |
| error_code="gradio_poll_timeout", | |
| error_message=f"no terminal status within {max_wait}s") | |
| raise gr.Error(f"Job {job_id} did not finish in {max_wait}s") | |
| if job["status"] != "done": | |
| msg = job.get("error") or "Job did not complete." | |
| raise gr.Error(msg) | |
| download_url = job.get("download_url", "") | |
| if not download_url: | |
| raise gr.Error("Job done but no download_url returned") | |
| filename = download_url.rsplit("/", 1)[-1] | |
| fbx_path = _OUTPUT_DIR / filename | |
| glb_path = fbx_path.with_suffix(".glb") | |
| out_path = glb_path if glb_path.exists() else fbx_path | |
| if not out_path.exists(): | |
| raise gr.Error(f"Output file missing: {out_path}") | |
| progress(1.0, desc="Done") | |
| input_type = (body.get("input") or {}).get("type", "?") | |
| model_name = body.get("model", "?") | |
| duration_val = body.get("duration", "?") | |
| summary = ( | |
| f"Job {job_id} Β· task={input_type} Β· model={model_name} Β· duration={duration_val}s" | |
| ) | |
| rewritten = job.get("rewritten_prompt") or "" | |
| original = job.get("original_prompt") or "" | |
| if rewritten and rewritten.strip() != (original or "").strip(): | |
| rewritten_display = rewritten | |
| else: | |
| rewritten_display = "" | |
| # JSON-serialise snap_info into a hidden textbox slot. Browser-direct | |
| # callers (the AnimoFlow web app) read data[3] | |
| # and surface it in the debug-response panel β same per-gen "did the | |
| # snap run?" receipt that REST clients get via JobResponse.snap_info. | |
| snap_info_json = json.dumps(job.get("snap_info")) | |
| yield str(out_path), summary, rewritten_display, snap_info_json | |
| def _list_characters() -> list[str]: | |
| """Best-effort character list from /v1/characters; fall back to filesystem.""" | |
| try: | |
| r = httpx.get( | |
| f"{_API_BASE}/v1/characters", | |
| headers={"Authorization": f"Bearer {_API_KEY}"}, | |
| timeout=5.0, | |
| ) | |
| r.raise_for_status() | |
| names = r.json().get("characters", []) | |
| if names: | |
| return names | |
| except Exception: # noqa: BLE001 β best effort during boot | |
| pass | |
| chars_dir = Path( | |
| os.environ.get("CHARACTERS_DIR", "/opt/comfyui-animoflow/characters") | |
| ) | |
| if chars_dir.is_dir(): | |
| return sorted(p.stem for p in chars_dir.glob("*.fbx")) | |
| return ["Y_bot"] | |
| def build_blocks() -> gr.Blocks: | |
| """Construct the Gradio Blocks app. Called once at orchestrator startup.""" | |
| # Pull the character list once at boot. Refreshed on demand via the | |
| # "β»" button next to the dropdown. | |
| initial_chars = _list_characters() or ["Y_bot"] | |
| with gr.Blocks(title="AnimoFlow Demo", theme=gr.themes.Soft()) as blocks: | |
| gr.Markdown( | |
| """ | |
| # AnimoFlow β Text β Motion β FBX | |
| Type a description, pick a character, hit **Generate**. | |
| Powered by [MDM](https://guytevet.github.io/mdm-page/) + | |
| momask Joint2BVH IK + Blender retarget. | |
| Same `/v1/jobs` API as the local OSS stack β you can also call | |
| it from `gradio_client`, `@gradio/client` JS, or curl. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| prompt = gr.Textbox( | |
| label="Prompt", | |
| placeholder="a person walks forward and waves", | |
| lines=3, | |
| ) | |
| model = gr.Dropdown( | |
| choices=["mdm", "momask", "kimodo"], | |
| value="mdm", | |
| label="Model", | |
| ) | |
| character = gr.Dropdown( | |
| choices=initial_chars, | |
| value=initial_chars[0], | |
| label="Character", | |
| ) | |
| duration = gr.Slider( | |
| minimum=1.0, | |
| maximum=10.0, | |
| value=4.0, | |
| step=0.5, | |
| label="Duration (seconds)", | |
| ) | |
| seed = gr.Number( | |
| value=-1, label="Seed (-1 = random)", precision=0 | |
| ) | |
| submit = gr.Button("Generate", variant="primary") | |
| with gr.Column(scale=2): | |
| viewer = gr.Model3D( | |
| label="Preview", display_mode="solid", clear_color=[0, 0, 0, 0] | |
| ) | |
| status = gr.Textbox(label="Job", interactive=False, lines=2) | |
| # Surfaces the rewritten prompt when the multilingual rewriter | |
| # actually fires (non-empty + different from the original). The | |
| # textbox stays blank for English-HumanML3D-style inputs that | |
| # the cheap skip heuristic short-circuits. Original/rewritten | |
| # are also in /v1/jobs/{id} for API consumers. | |
| rewritten_box = gr.Textbox( | |
| label="Rewritten as (what the model actually saw)", | |
| interactive=False, | |
| lines=2, | |
| placeholder="(your prompt was kept as-is)", | |
| ) | |
| submit.click( | |
| fn=_generate, | |
| inputs=[prompt, model, character, duration, seed], | |
| outputs=[viewer, status, rewritten_box], | |
| # Stable named endpoint so the browser-side @gradio/client | |
| # in animoflow-api can call Client.predict("/generate", ...). | |
| api_name="generate", | |
| ) | |
| # Hidden API-only endpoint that accepts the full GenerateRequest | |
| # body as a JSON string. Used by animoflow-api's browser-side | |
| # @gradio/client for all four task types (text / trajectory / | |
| # waypoints / timeline). Not visible in the Space's own UI β | |
| # external callers reach it via Client.predict("/generate_full", ...). | |
| request_json_in = gr.Textbox(visible=False) | |
| snap_info_box = gr.Textbox(visible=False) | |
| submit_full = gr.Button(visible=False) | |
| submit_full.click( | |
| fn=_generate_full, | |
| inputs=[request_json_in], | |
| outputs=[viewer, status, rewritten_box, snap_info_box], | |
| api_name="generate_full", | |
| ) | |
| gr.Markdown( | |
| """ | |
| ### API access | |
| ```bash | |
| curl -X POST $API/v1/jobs \\ | |
| -H "Authorization: Bearer dev" \\ | |
| -H "Content-Type: application/json" \\ | |
| -d '{"input":{"type":"text","prompt":"a person walks"},"model":"mdm","duration":4}' | |
| ``` | |
| Then poll `/v1/jobs/{id}` until `status=done` and download | |
| `/v1/files/{id}.fbx` (or `.glb`). | |
| """ | |
| ) | |
| return blocks | |