mocapgod/mocap
type-two c918ac3dcd lane C+D: retarget + mocap CLI + docs; fix: FBX take carries the clip name (was Armature|Scene for every clip — bank collisions in take-addressed consumers)
First generated-footage clip proven through the full chain: Wan2.1 T2V (m3ultra, prompt-engineered to CAPTURE.md rules) -> ./mocap -> wan_walk_t1.fbx -> merge_anims bank (49 tracks). PROGRESS.md bug closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:06:38 +10:00

120 lines
5.5 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/<date>_<name>.mp4 (MOVES the source)
Lane B pose_engine.py video → HSMR/SKEL → out/<stem>/motion.bvh + sidecar [HSMR venv, MPS]
Lane C retarget.py BVH → mixamorig → clips_out/<name>.fbx + <name>_qc.mp4 [headless Blender]
Result: clips_out/<name>.fbx (anim-only, Mixamo "Without Skin") + <name>_qc.mp4 turntable.
House rule: eyeball the QC mp4 before the clip enters a bank.
Deferred hooks (off by default — built when John's real footage lands; see docs/RUNBOOK.md):
clip-bank GLB merge, --to-unreal, --publish-3god. merge_anims.py needs a CLI + the Blender-5
slot-bind first, and none of them are verifiable without a real clip, 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"
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")
print("selfcheck OK — name threads inbox→queue→out→clips, 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/<name>.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("--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():
sys.exit(f"✗ HSMR venv missing: {VENV_PY} — run .engine/setup_hsmr.sh (Lane B).")
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}")
print(" merge-to-GLB / Unreal / 3god hooks are deferred — docs/RUNBOOK.md.")
return 0
if __name__ == "__main__":
sys.exit(main())