vid2rig / rig_io.py
BladeSzaSza's picture
Upload folder using huggingface_hub
3d348c4 verified
Raw
History Blame Contribute Delete
6.53 kB
import os, shutil, subprocess, tempfile
SUPPORTED = (".glb", ".gltf", ".fbx", ".dae")
_SCRIPT = os.path.join(os.path.dirname(__file__), "tools", "fbx2glb.mjs")
_NODE_DIR = os.environ.get("VID2RIG_NODE_DIR", "/tmp/vid2rig_node")
def _find(cmd):
"""Locate a binary even if it's not on the Python process PATH (HF runtime PATH != build PATH)."""
return shutil.which(cmd) or next(
(p for p in (f"/usr/local/bin/{cmd}", f"/usr/bin/{cmd}", f"/opt/homebrew/bin/{cmd}")
if os.path.exists(p)), None)
def _node_convert(src: str, out: str) -> bool:
"""FBX -> GLB via three.js FBXLoader + GLTFExporter (the path the mule scenes use). Correctly
collapses Mixamo $AssimpFbx$ pivots, fixes the up-axis, and writes TRS bones so the baked
animation is valid. Installs `three` into a work dir on first use. Returns True on success."""
node, npm = _find("node"), _find("npm")
if not node or not npm or not os.path.exists(_SCRIPT):
print(f"[rig_io] node convert unavailable (node={node} npm={npm} script={os.path.exists(_SCRIPT)})", flush=True)
return False
src, out = os.path.abspath(src), os.path.abspath(out) # node runs with cwd=_NODE_DIR
os.makedirs(_NODE_DIR, exist_ok=True)
shutil.copy(_SCRIPT, os.path.join(_NODE_DIR, "fbx2glb.mjs"))
if not os.path.isdir(os.path.join(_NODE_DIR, "node_modules", "three")):
r = subprocess.run([npm, "install", "--no-audit", "--no-fund", "three@0.169.0"],
cwd=_NODE_DIR, capture_output=True, text=True)
if r.returncode != 0:
print("[rig_io] npm install three FAILED:", (r.stderr or r.stdout)[-500:], flush=True)
return False
r = subprocess.run([node, os.path.join(_NODE_DIR, "fbx2glb.mjs"), src, out],
cwd=_NODE_DIR, capture_output=True, text=True)
ok = r.returncode == 0 and os.path.exists(out)
if ok:
print("[rig_io] three.js conversion OK ->", out, flush=True)
else:
print("[rig_io] three.js conversion FAILED:", (r.stderr or r.stdout)[-500:], flush=True)
return ok
def _embedded_png(fbx: str):
"""(bytes, mime) of the FBX's first embedded texture via assimp, or None."""
assimp = _find("assimp")
if not assimp:
return None
try:
import pygltflib as gl
tmp = tempfile.mktemp(suffix=".glb")
subprocess.run([assimp, "export", fbx, tmp], check=True, capture_output=True)
ga = gl.GLTF2().load(tmp)
if not ga.images:
return None
im = ga.images[0]; bv = ga.bufferViews[im.bufferView]
png = (ga.binary_blob() or b"")[(bv.byteOffset or 0):(bv.byteOffset or 0) + bv.byteLength]
return png, (im.mimeType or "image/png")
except Exception:
return None
def _finalize_rig(glb: str, fbx: str) -> None:
"""Post-process the clean three.js GLB in place: (1) point skins[0].skeleton at Hips
(fixes SKIN_SKELETON_INVALID), (2) transplant the FBX's embedded texture onto the material
(three.js export drops it but keeps the matching UV0). Best-effort; failures leave a valid rig."""
try:
import pygltflib as gl
g = gl.GLTF2().load(glb)
changed = False
if g.skins:
hips = next((i for i, n in enumerate(g.nodes)
if n.name and n.name.replace(":", "").replace("_", "") == "mixamorigHips"), None)
if hips is not None and g.skins[0].skeleton != hips:
g.skins[0].skeleton = hips; changed = True
tex = _embedded_png(fbx)
prims = [p for m in (g.meshes or []) for p in m.primitives
if getattr(p.attributes, "TEXCOORD_0", None) is not None]
if tex and prims:
png, mime = tex
blob = bytearray(g.binary_blob() or b"")
while len(blob) % 4:
blob.append(0)
off = len(blob); blob += png
g.bufferViews.append(gl.BufferView(buffer=0, byteOffset=off, byteLength=len(png)))
g.images = (g.images or []) + [gl.Image(bufferView=len(g.bufferViews) - 1, mimeType=mime)]
g.samplers = (g.samplers or []) + [gl.Sampler()]
g.textures = (g.textures or []) + [gl.Texture(source=len(g.images) - 1, sampler=len(g.samplers) - 1)]
tex_i = len(g.textures) - 1
for prim in prims:
if prim.material is None:
g.materials = (g.materials or []) + [gl.Material()]; prim.material = len(g.materials) - 1
mat = g.materials[prim.material]
if mat.pbrMetallicRoughness is None:
mat.pbrMetallicRoughness = gl.PbrMetallicRoughness()
mat.pbrMetallicRoughness.baseColorTexture = gl.TextureInfo(index=tex_i, texCoord=0)
mat.pbrMetallicRoughness.baseColorFactor = [1, 1, 1, 1]
g.set_binary_blob(bytes(blob)); g.buffers[0].byteLength = len(blob)
changed = True
print("[rig_io] texture transplanted onto rig", flush=True)
if changed:
g.save(glb)
except Exception as e:
print("[rig_io] _finalize_rig error:", repr(e), flush=True)
return
def to_glb(path: str) -> str:
"""Return a .glb path for the rig. .glb/.gltf pass through; .fbx is converted via three.js
(preferred — handles Mixamo rigs correctly); assimp is a last-resort fallback (it mangles
Mixamo pivots, so only used when node/three is unavailable, and for .dae)."""
ext = os.path.splitext(path)[1].lower()
if ext in (".glb", ".gltf"):
return path
if ext not in SUPPORTED:
raise ValueError(f"unsupported rig format {ext!r}; use one of {SUPPORTED}")
out = tempfile.mktemp(suffix=".glb")
if ext == ".fbx" and _node_convert(path, out):
_finalize_rig(out, path) # fix skin root + borrow embedded texture
return out
print("[rig_io] FALLING BACK to assimp (mangles Mixamo rig + no texture)", flush=True)
assimp = _find("assimp")
if assimp:
subprocess.run([assimp, "export", path, out], check=True, capture_output=True)
return out
fbx2gltf = shutil.which("FBX2glTF") or shutil.which("fbx2gltf")
if fbx2gltf and ext == ".fbx":
subprocess.run([fbx2gltf, "-i", path, "-o", out, "--binary"], check=True, capture_output=True)
return out if os.path.exists(out) else out + ".glb"
raise RuntimeError(
"No FBX/DAE converter available. Need Node.js + three (preferred) or `brew install assimp`.")