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>
120 lines
5.5 KiB
Python
Executable File
120 lines
5.5 KiB
Python
Executable File
#!/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())
|