Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python | |
| """ | |
| Kimodo escape-hatch runner. | |
| Contract (matches escape_hatch/invoke.py:invoke): | |
| - Stdin: JSON {prompt, num_frames, seed, guidance_param, | |
| num_denoising_steps?, cfg_text?, cfg_constraint?, | |
| root2d?: {"frame_indices": [int], "smooth_root_2d": [[x,z]]}} | |
| num_frames is already in Kimodo's native 30 fps β animoflow-api | |
| converts durationβframes upstream (api/main.py:623-633). | |
| root2d is the unified XZ floor-plane constraint that powers both | |
| "trajectory" (dense frames) and "waypoint" (sparse frames) tasks. | |
| When omitted, Kimodo runs unconstrained textβmotion. | |
| - Stdout: BVH bytes (22-joint MoMask hierarchy) from the SMPL-free | |
| rotation-carrying calibrated converter (container fmt="bvh22"). | |
| The rotations carry the pose, so the downstream HF pipeline skips | |
| IK and consumes the BVH directly (retarget β GLB). | |
| - Stderr: free-form logs (escape_hatch surfaces last 400 chars on failure). | |
| - Exit: 0 success, non-zero on any failure. | |
| Per the no-silent-fallback policy: every failure raises. No placeholder. | |
| Runs from inside the Kimodo venv at $KIMODO_VENV_PYTHON. The orchestrator | |
| (animoflow-app) spawns this subprocess from inside @spaces.GPU. | |
| """ | |
| from __future__ import annotations | |
| import importlib.util | |
| import json | |
| import os | |
| import sys | |
| import traceback | |
| from pathlib import Path | |
| # --------------------------------------------------------------------------- | |
| # Stdout is reserved for NPZ bytes β everything else goes to stderr. | |
| # CRITICAL: kimodo / transformers / py-soma-x all print() to stdout | |
| # (model load banners, LLM2Vec patch messages, progress bars). Without | |
| # diversion those bytes would prepend to the NPZ payload and `np.load` | |
| # would fail with "Failed to interpret file as a pickle". We dup the | |
| # original stdout fd, redirect Python-level sys.stdout AND fd 1 to | |
| # stderr for the duration of the library calls, then restore the | |
| # saved fd only when we're ready to write the NPZ bytes. | |
| # --------------------------------------------------------------------------- | |
| _STDOUT_BACKUP_FD: int | None = None | |
| def _divert_stdout_to_stderr() -> None: | |
| """Redirect sys.stdout AND raw fd 1 to stderr so library `print()`s | |
| don't corrupt our binary stdout payload. Saves the original fd so | |
| we can restore it just before writing the NPZ.""" | |
| global _STDOUT_BACKUP_FD | |
| if _STDOUT_BACKUP_FD is not None: | |
| return # idempotent | |
| sys.stdout.flush() | |
| _STDOUT_BACKUP_FD = os.dup(1) | |
| os.dup2(2, 1) # point fd 1 at fd 2 (stderr) | |
| sys.stdout = sys.stderr | |
| def _restore_stdout_for_npz() -> None: | |
| """Restore the original stdout fd for the final NPZ write.""" | |
| global _STDOUT_BACKUP_FD | |
| if _STDOUT_BACKUP_FD is None: | |
| return | |
| sys.stderr.flush() | |
| os.dup2(_STDOUT_BACKUP_FD, 1) | |
| os.close(_STDOUT_BACKUP_FD) | |
| _STDOUT_BACKUP_FD = None | |
| sys.stdout = os.fdopen(1, "wb", closefd=False) | |
| def _log(msg: str) -> None: | |
| print(f"[kimodo-runner] {msg}", file=sys.stderr, flush=True) | |
| def _fail(msg: str, code: int = 1) -> "None": | |
| _log(f"FAIL: {msg}") | |
| sys.exit(code) | |
| # --------------------------------------------------------------------------- | |
| # Sentinel checks β defence-in-depth (escape_hatch.invoke gates first, but the | |
| # runner must also refuse to start if the venv build hasn't finished or failed). | |
| # --------------------------------------------------------------------------- | |
| _EXTERNAL_DIR = Path( | |
| os.environ.get( | |
| "ANIMOFLOW_EXTERNAL_DIR", | |
| "/home/user/app/external" | |
| if os.path.isdir("/home/user/app") | |
| else str(Path(__file__).resolve().parent.parent / "external"), | |
| ) | |
| ) | |
| _READY_SENTINEL = _EXTERNAL_DIR / ".kimodo_ready" | |
| _FAILED_SENTINEL = _EXTERNAL_DIR / ".kimodo_failed" | |
| def _check_sentinels() -> None: | |
| if _FAILED_SENTINEL.is_file(): | |
| body = "" | |
| try: | |
| body = _FAILED_SENTINEL.read_text()[:1200] | |
| except OSError: | |
| pass | |
| _fail( | |
| f"Kimodo venv build previously failed:\n{body}\n" | |
| f"Delete {_FAILED_SENTINEL} and restart the Space to retry." | |
| ) | |
| if not _READY_SENTINEL.is_file(): | |
| _fail( | |
| f"Kimodo venv not ready (sentinel missing: {_READY_SENTINEL}). " | |
| "The bootstrap thread may still be running." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Import the AnimoFlow-owned helpers from comfyui-animoflow/containers/kimodo/app.py. | |
| # | |
| # The container's app.py constructs a FastAPI app at module top, but nothing | |
| # serves it β importing it has no runtime cost beyond the import statements. | |
| # We reuse its _patch_llm2vec_for_ungated_llama, _load_model, and | |
| # _motion_to_output helpers verbatim per [[Wrap, don't fork upstream | |
| # model repos]] (this file is AnimoFlow-authored, not upstream NVIDIA). | |
| # --------------------------------------------------------------------------- | |
| def _load_kimodo_app_module(): | |
| comfy_root = os.environ.get("COMFYUI_ANIMOFLOW_DIR") | |
| if not comfy_root: | |
| _fail( | |
| "COMFYUI_ANIMOFLOW_DIR not set β the runner can't locate the " | |
| "containers/kimodo/app.py helper module." | |
| ) | |
| container_dir = Path(comfy_root) / "containers" / "kimodo" | |
| app_py = container_dir / "app.py" | |
| if not app_py.is_file(): | |
| _fail(f"containers/kimodo/app.py not found at {app_py}") | |
| # Make `soma_smpl22_bvh` (a sibling module of app.py) importable. | |
| if str(container_dir) not in sys.path: | |
| sys.path.insert(0, str(container_dir)) | |
| # Make the Kimodo source tree importable. | |
| kimodo_src = os.environ.get("KIMODO_SRC_DIR") | |
| if kimodo_src and kimodo_src not in sys.path: | |
| sys.path.insert(0, kimodo_src) | |
| spec = importlib.util.spec_from_file_location("kimodo_helpers", app_py) | |
| if spec is None or spec.loader is None: | |
| _fail(f"importlib failed to build spec for {app_py}") | |
| mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] | |
| spec.loader.exec_module(mod) # type: ignore[union-attr] | |
| return mod | |
| # Memoize the loaded model across calls in the same subprocess. In practice | |
| # this is one call per @spaces.GPU subprocess, but cheap insurance. | |
| _KIMODO_MODEL = None | |
| _KIMODO_HELPERS = None | |
| def _ensure_model_loaded(): | |
| global _KIMODO_MODEL, _KIMODO_HELPERS | |
| if _KIMODO_MODEL is not None: | |
| return _KIMODO_MODEL, _KIMODO_HELPERS | |
| _KIMODO_HELPERS = _load_kimodo_app_module() | |
| _log(f"loaded helpers from containers/kimodo/app.py (device={_KIMODO_HELPERS.DEVICE})") | |
| # _load_model populates the module-global _model AND patches LLM2Vec configs | |
| # to point at the ungated LLaMA mirror. Idempotent. | |
| _KIMODO_HELPERS._load_model() | |
| _KIMODO_MODEL = _KIMODO_HELPERS._model | |
| if _KIMODO_MODEL is None: | |
| _fail("_load_model() returned but kimodo_helpers._model is still None") | |
| _log(f"model ready: {type(_KIMODO_MODEL).__name__}") | |
| return _KIMODO_MODEL, _KIMODO_HELPERS | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main() -> int: | |
| _check_sentinels() | |
| # Read stdin payload BEFORE diverting stdout (we don't write to stdout | |
| # while reading). | |
| try: | |
| payload = json.loads(sys.stdin.read()) | |
| except Exception as exc: # noqa: BLE001 | |
| _fail(f"could not parse stdin JSON: {exc}") | |
| return 2 # unreachable β _fail exits, but keeps type-checkers happy | |
| # From here on, every library print() must go to stderr until we're | |
| # ready to dump the NPZ bytes. Kimodo / transformers / py-soma-x all | |
| # print to stdout (model load banners, "[Kimodo] patching adapter_config" | |
| # etc.) β without this diversion the NPZ payload is prepended with text | |
| # and `np.load(BytesIO(stdout_bytes))` fails with "Failed to interpret | |
| # file as a pickle". | |
| _divert_stdout_to_stderr() | |
| prompt = payload.get("prompt") | |
| num_frames = payload.get("num_frames") | |
| seed = payload.get("seed") | |
| if not isinstance(prompt, str) or not prompt.strip(): | |
| _fail("payload.prompt missing or empty") | |
| if not isinstance(num_frames, int) or num_frames <= 0: | |
| _fail(f"payload.num_frames missing or non-positive: {num_frames!r}") | |
| if not isinstance(seed, int): | |
| _fail(f"payload.seed missing or not an int: {seed!r}") | |
| num_denoising_steps = int(payload.get("num_denoising_steps", 100)) | |
| # Kimodo's cfg_weight is [cfg_text, cfg_constraint]. The animoflow-api UI | |
| # exposes a single `cfg` knob which pipeline_hf maps to both. The runner | |
| # accepts either explicit split values or the single guidance_param. | |
| if "cfg_text" in payload or "cfg_constraint" in payload: | |
| cfg_text = float(payload.get("cfg_text", 2.0)) | |
| cfg_constraint = float(payload.get("cfg_constraint", 2.0)) | |
| else: | |
| gp = payload.get("guidance_param") | |
| cfg = float(gp) if gp is not None else 2.0 | |
| cfg_text = cfg | |
| cfg_constraint = cfg | |
| # Unified root2d constraint covers both trajectory (dense) and waypoint | |
| # (sparse) tasks. pipeline_hf builds the dict; we forward it to Kimodo's | |
| # constraint loader. Fail-fast on malformed shapes β no silent fallback. | |
| root2d = payload.get("root2d") | |
| if root2d is not None: | |
| if not isinstance(root2d, dict): | |
| _fail(f"payload.root2d must be a dict, got {type(root2d).__name__}") | |
| frame_indices = root2d.get("frame_indices") | |
| smooth_root_2d = root2d.get("smooth_root_2d") | |
| if not isinstance(frame_indices, list) or not frame_indices: | |
| _fail("payload.root2d.frame_indices must be a non-empty list of ints") | |
| if not isinstance(smooth_root_2d, list) or not smooth_root_2d: | |
| _fail("payload.root2d.smooth_root_2d must be a non-empty list of [x,z] pairs") | |
| if len(frame_indices) != len(smooth_root_2d): | |
| _fail( | |
| f"payload.root2d length mismatch: frame_indices={len(frame_indices)} " | |
| f"vs smooth_root_2d={len(smooth_root_2d)}" | |
| ) | |
| for i, p in enumerate(smooth_root_2d): | |
| if not (isinstance(p, list) and len(p) == 2): | |
| _fail(f"payload.root2d.smooth_root_2d[{i}] must be [x, z]; got {p!r}") | |
| max_frame = max(frame_indices) | |
| if max_frame >= num_frames or min(frame_indices) < 0: | |
| _fail( | |
| f"payload.root2d.frame_indices out of range [0, {num_frames}): " | |
| f"min={min(frame_indices)} max={max_frame}" | |
| ) | |
| model, helpers = _ensure_model_loaded() | |
| # Match the container's _generate_worker call path (containers/kimodo/app.py:472-481). | |
| import torch | |
| from kimodo.tools import seed_everything | |
| seed_everything(int(seed)) | |
| # Build the constraint_lst kwarg. load_constraints_lst takes either a JSON | |
| # path or a list[dict] directly; we pass a list[dict] to avoid file I/O. | |
| # See kimodo/constraints.py:566-593 (TYPE_TO_CLASS["root2d"] β Root2DConstraintSet). | |
| constraint_lst: list = [] | |
| if root2d is not None: | |
| from kimodo.constraints import load_constraints_lst | |
| dev = next(model.parameters()).device if hasattr(model, "parameters") else None | |
| constraint_lst = load_constraints_lst( | |
| [{"type": "root2d", | |
| "frame_indices": [int(f) for f in root2d["frame_indices"]], | |
| "smooth_root_2d": [[float(x), float(z)] for (x, z) in root2d["smooth_root_2d"]]}], | |
| model.skeleton, | |
| device=dev, | |
| dtype=torch.float32, | |
| ) | |
| _log( | |
| f"root2d constraint: {len(root2d['frame_indices'])} frames " | |
| f"(min={min(root2d['frame_indices'])} max={max(root2d['frame_indices'])}) " | |
| f"XZ range x=[{min(p[0] for p in root2d['smooth_root_2d']):.2f}," | |
| f"{max(p[0] for p in root2d['smooth_root_2d']):.2f}] " | |
| f"z=[{min(p[1] for p in root2d['smooth_root_2d']):.2f}," | |
| f"{max(p[1] for p in root2d['smooth_root_2d']):.2f}]" | |
| ) | |
| _log( | |
| f"generating: prompt={prompt[:60]!r} num_frames={num_frames} " | |
| f"seed={seed} steps={num_denoising_steps} cfg=[{cfg_text},{cfg_constraint}] " | |
| f"constraints={len(constraint_lst)}" | |
| ) | |
| with torch.no_grad(): | |
| output = model( | |
| prompts=prompt, | |
| num_frames=num_frames, | |
| num_denoising_steps=num_denoising_steps, | |
| cfg_weight=[cfg_text, cfg_constraint], | |
| constraint_lst=constraint_lst, | |
| return_numpy=False, | |
| post_processing=False, | |
| ) | |
| # SOMA β 22-joint rig-ready BVH (SMPL-free rotation-carrying calibrated path). | |
| # The rotations carry the pose, so this feeds the rig stage directly β the | |
| # orchestrator's plan skips IK for Kimodo. | |
| data, is_npz = helpers._motion_to_output(output, model.output_skeleton, fmt="bvh22") | |
| if is_npz: | |
| _fail("_motion_to_output returned is_npz=True for fmt='bvh22' β expected a BVH string") | |
| if not isinstance(data, str) or not data: | |
| _fail(f"_motion_to_output returned empty/non-str BVH payload: {type(data)}") | |
| # Defensive: sanity-check the BVH header before handing it to the rig stage. | |
| bvh_bytes = data.encode() | |
| nframes = next((int(l.split(":", 1)[1]) for l in data.splitlines() | |
| if l.startswith("Frames:")), None) | |
| if "HIERARCHY" not in data or nframes is None: | |
| _fail("bvh22 output missing HIERARCHY / Frames header") | |
| _log(f"output: BVH {nframes} frames ({len(bvh_bytes)//1024} KB)") | |
| # Restore the saved stdout fd and write the BVH payload as raw bytes. | |
| _restore_stdout_for_npz() | |
| os.write(1, bvh_bytes) | |
| return 0 | |
| if __name__ == "__main__": | |
| try: | |
| sys.exit(main()) | |
| except SystemExit: | |
| raise | |
| except Exception as exc: # noqa: BLE001 | |
| # Top-level guard so any uncaught error lands as a clear stderr trace | |
| # rather than a silent non-zero exit. No silent fallback. | |
| print(f"[kimodo-runner] FATAL UNCAUGHT: {type(exc).__name__}: {exc}", file=sys.stderr) | |
| traceback.print_exc(file=sys.stderr) | |
| sys.exit(1) | |