mocapgod/mocap
type-two 4af9033b68 Lane D: --merge clip-bank GLB hook + honest ledger of the three silent bugs
--merge folds clips_out/*.fbx onto the character's bank GLB via character_kit's
merge_anims.py (already CLI'd + slot-bound; fps pin added there this session) ->
clips_out/<character>_bank.glb. Rebuilt from scratch each run, so it's idempotent.
This is the 'lands in 90sDJsim' step: games load GLB.

Verified from the exported glTF JSON rather than Blender (which resamples on read):
27 animations (24 base + 3 ours), no duplicate names, all 30.0 keys/sec, the 317-frame
clip keeps 317 keys over 10.567s, our clips drive real bone motion (delta 48-184u).

Also: mocap now reports a DANGLING venv symlink honestly (uv pruned its managed
CPython 3.10, which makes Path.exists() read as 'missing'), selfcheck covers the
bank path, RUNBOOK documents --merge + the env gotcha, PROGRESS records all three
bugs and the lesson (check artifacts, not green log lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:52 +10:00

147 lines
7.1 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.
--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/<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("--merge", action="store_true",
help="also merge ALL clips_out/*.fbx onto the character's bank GLB "
"→ clips_out/<character>_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())