#!/usr/bin/env python3 """MOCAPGOD Lane D — `mocap`: one command, phone video → game-ready mocap clip. ./mocap capture/inbox/vid.mp4 --name dj_scratch_01 ./mocap spin.mov --name spin --character male --travel Chains the three proven stages, all on ultra: Lane A ingest.py video → 30fps h264 → queue/_.mp4 (MOVES the source) Lane B pose_engine.py video → HSMR/SKEL → out//motion.bvh + sidecar [HSMR venv, MPS] Lane C retarget.py BVH → mixamorig → clips_out/.fbx + _qc.mp4 [headless Blender] Result: clips_out/.fbx (anim-only, Mixamo "Without Skin") + _qc.mp4 turntable. House rule: eyeball the QC mp4 before the clip enters a bank. --merge additionally folds clips_out/*.fbx onto the character's bank GLB (games load GLB, so this is the "lands in 90sDJsim" step) via character_kit/scripts/merge_anims.py. Deferred hooks (off by default — see docs/RUNBOOK.md): --to-unreal, --publish-3god. Neither is verifiable without a real clip in the target project, so they're not faked here. """ import argparse, datetime, re, subprocess, sys from pathlib import Path REPO = Path(__file__).resolve().parent QUEUE = REPO / "queue" OUT = REPO / "out" CLIPS = REPO / "clips_out" VENV_PY = REPO / ".engine/HSMR/.venv/bin/python" # Lane B lives in its own env BLENDER = Path("/Applications/Blender.app/Contents/MacOS/Blender") KIT = Path.home() / "Documents/character_kit" MERGE = KIT / "scripts/merge_anims.py" # CLI: -- BASE CLIP_DIR OUT (Blender-5 slot-bind inside) RIGS = { # --character → (pose gender, retarget rig) "female": ("female", KIT / "female/female_game.glb"), # v1 proven path "male": ("male", KIT / "hum_character.glb"), # ponytail: male rig unverified until a male clip exists } def slug(s): # mirrors ingest.slug (kept standalone on purpose) return re.sub(r"[^A-Za-z0-9_-]+", "_", s).strip("_") or "clip" def paths_for(name): """The naming contract that threads a clip through all three stages. The one bit of real logic here, so it gets the self-check below.""" stem = f"{datetime.date.today():%Y%m%d}_{name}" # ingest's queue naming return stem, QUEUE / f"{stem}.mp4", OUT / stem / "motion.bvh", CLIPS / f"{name}.fbx" def stage(title, cmd): print(f"\n▶ {title}\n $ {' '.join(str(c) for c in cmd)}", flush=True) if subprocess.run([str(c) for c in cmd]).returncode != 0: sys.exit(f"✗ {title} failed — chain stopped.") def selfcheck(): """Path-threading only: no engine, no network, no Blender.""" stem, q, bvh, fbx = paths_for("dj_scratch_01") assert stem.endswith("_dj_scratch_01"), stem assert q == QUEUE / f"{stem}.mp4", q assert bvh == OUT / stem / "motion.bvh", bvh assert fbx == CLIPS / "dj_scratch_01.fbx", fbx # final name stays clean (retarget --name) assert slug("DJ Scratch #1!") == "DJ_Scratch_1", slug("DJ Scratch #1!") for c in RIGS.values(): assert c[0] in ("male", "female") # --merge writes one bank per character, and must not glob its own output back in for ch in RIGS: assert (CLIPS / f"{ch}_bank.glb").suffix == ".glb", ch print("selfcheck OK — name threads inbox→queue→out→clips→bank, final clip name stays clean") return 0 def main(): ap = argparse.ArgumentParser(description="Lane D: one command, video → game-ready mocap clip.") ap.add_argument("video", nargs="?", help="source clip (e.g. capture/inbox/move.mp4) — ingest MOVES it") ap.add_argument("--name", help="clip name → clips_out/.fbx") ap.add_argument("--character", choices=list(RIGS), default="female") ap.add_argument("--gender", choices=["male", "female"], help="override pose gender (default: from --character)") ap.add_argument("--travel", action="store_true", help="keep world root translation (default: in-place)") ap.add_argument("--no-qc", action="store_true", help="skip the QC turntable render") ap.add_argument("--merge", action="store_true", help="also merge ALL clips_out/*.fbx onto the character's bank GLB " "→ clips_out/_bank.glb (the 'lands in-game' step)") ap.add_argument("--selfcheck", action="store_true", help="verify the path-threading logic and exit") a = ap.parse_args() if a.selfcheck: return selfcheck() if not a.video or not a.name: ap.error("video and --name are required (or use --selfcheck)") if not VENV_PY.exists(): # .exists() is False for a DANGLING symlink too — uv pruning its managed CPython 3.10 # breaks the venv this way (seen 2026-07-25). Re-running setup rebuilds it. sys.exit(f"✗ HSMR venv missing or broken: {VENV_PY}\n" f" (a dangling interpreter symlink counts — check: ls -la {VENV_PY})\n" f" rebuild with: ./setup_hsmr.sh") if not BLENDER.exists(): sys.exit(f"✗ Blender not found: {BLENDER} (Lane C needs it).") name = slug(a.name) gender, rig = RIGS[a.character] gender = a.gender or gender if not rig.exists(): sys.exit(f"✗ rig for --character {a.character} not found: {rig}") stem, queued, bvh, fbx = paths_for(name) stage("Lane A · ingest", ["python3", REPO / "ingest.py", a.video, "--name", name]) if not queued.exists(): sys.exit(f"✗ no {queued} after ingest — clip rejected? check capture/LEDGER.tsv") stage("Lane B · pose_engine (HSMR/SKEL, MPS — slow, ~1.6× realtime)", [VENV_PY, REPO / "pose_engine.py", queued, "--gender", gender]) if not bvh.exists(): sys.exit(f"✗ no {bvh} after pose_engine.") rt = [BLENDER, "--background", "--python", REPO / "retarget.py", "--", "--bvh", bvh, "--rig", rig, "--name", name] if a.travel: rt.append("--travel") if a.no_qc: rt.append("--no-qc") stage("Lane C · retarget + QC (Blender)", rt) if not fbx.exists(): sys.exit(f"✗ no {fbx} after retarget.") qc = CLIPS / f"{name}_qc.mp4" print(f"\n✅ clip ready: {fbx}") if qc.exists(): print(f" eyeball before it enters a bank: {qc}") bank = CLIPS / f"{a.character}_bank.glb" if a.merge: if not MERGE.exists(): sys.exit(f"✗ merge script missing: {MERGE}") # the bank is rebuilt from scratch each time = base rig + EVERY fbx currently in clips_out/ # (idempotent, no compounding). Prune clips_out to control what ships. print(f"\n bank = {rig.name} + all {len(list(CLIPS.glob('*.fbx')))} clip(s) in {CLIPS.name}/") stage("Lane D · merge to clip-bank GLB", [BLENDER, "--background", "--python", MERGE, "--", rig, CLIPS, bank]) if not bank.exists(): sys.exit(f"✗ no {bank} after merge.") print(f"\n✅ bank ready: {bank} → load this in 90sDJsim / three.js games") else: print(f" --merge folds it into {bank.name}; --to-unreal / --publish-3god: docs/RUNBOOK.md.") return 0 if __name__ == "__main__": sys.exit(main())