"""Learn humanoid skinning remotely and retain the production geometry/materials.""" from dataclasses import dataclass import json import os from pathlib import Path import shutil import subprocess import time from .artifacts import digest @dataclass(frozen=True) class RigResult: mesh: Path source_fbx: Path | None provenance: dict def rig_character(mesh: Path, output_dir: Path, notify=None) -> RigResult: provider = os.environ.get("STUDIO_RIG_PROVIDER", "mia") if provider == "skintokens": return _rig_skintokens(mesh, output_dir, notify) if provider != "mia": raise ValueError(f"Unknown rig provider: {provider}") return _rig_mia(mesh, output_dir, notify) def _rig_mia(mesh: Path, output_dir: Path, notify=None) -> RigResult: import httpx from gradio_client import Client, handle_file from huggingface_hub import HfApi, get_token output_dir.mkdir(parents=True, exist_ok=True) space = os.environ.get("STUDIO_RIG_SPACE", "jasongzy/Make-It-Animatable") token = os.environ.get("HF_TOKEN") or get_token() revision = HfApi(token=token).space_info(space).sha client = Client( space, token=token, download_files=output_dir / "provider", verbose=False, httpx_kwargs={"timeout": httpx.Timeout(180, connect=30)}, ) endpoint = client.view_api(return_format="dict", print_info=False)["named_endpoints"]["/pipeline"] restore_global = any(p["label"] == "Restore Global Transform" for p in endpoint["parameters"]) arguments = [handle_file(str(mesh.resolve())), False, "No", [], False, 0.01] if restore_global: arguments.append(True) arguments.extend([restore_global, True, "LeftArm", False, None, True, True]) job = client.submit(*arguments, api_name="/pipeline") receipt = { "provider": space, "provider_revision": revision, "endpoint": "/pipeline", "source_sha256": digest(mesh), "started": time.time(), "status": "running", "restore_global": restore_global, "reset_to_rest": False, "animation_file": None, } receipt_path = output_dir / "rigging.json" receipt_path.write_text(json.dumps(receipt, indent=2)) previous = None try: while not job.done(): status = job.status().code.name if status != previous and notify: notify(f"Learned character rig: {status.lower()}") previous = status time.sleep(2) results = job.result() # The generator's final yield can contain only Gradio skip updates. Keep the # last concrete downloadable FBX among all yielded outputs. candidates = [results, *job.outputs()] fbx = None normalized = None for result in candidates: for value in result if isinstance(result, (tuple, list)) else [result]: if isinstance(value, dict): value = value.get("value", value.get("path")) if isinstance(value, dict): value = value.get("path") if isinstance(value, str) and value.lower().endswith(".fbx") and Path(value).is_file(): fbx = Path(value) if isinstance(value, str) and Path(value).name == "normed.glb" and Path(value).is_file(): normalized = Path(value) if fbx is None: raise RuntimeError("Learned rig service did not return an FBX skin") source_fbx = output_dir / "learned-rig.fbx" shutil.copy2(fbx, source_fbx) blender = os.environ.get("BLENDER_BIN") or shutil.which("blender") if not blender: mac = Path("/Applications/Blender.app/Contents/MacOS/Blender") blender = str(mac) if mac.is_file() else None if not blender: raise RuntimeError("Blender is required to preserve production textures during rig transfer") rigged = output_dir / "rigged.glb" script = Path(__file__).resolve().parents[1] / "scripts/transfer_learned_rig.py" normalization_args = [] if not restore_global: if normalized is None: raise RuntimeError("Rig service omitted the normalization mesh needed to restore coordinates") normalization = output_dir / "normalization.json" normalization.write_text(json.dumps(recover_normalization(mesh, normalized), indent=2)) normalization_args = ["--normalization", str(normalization.resolve())] completed = subprocess.run( [ blender, "--background", "--python-exit-code", "1", "--python", str(script), "--", "--source", str(mesh.resolve()), "--rig", str(source_fbx.resolve()), "--output", str(rigged.resolve()), *normalization_args, ], capture_output=True, text=True, timeout=600, ) (output_dir / "transfer.log").write_text(completed.stdout + completed.stderr) if completed.returncode or not rigged.is_file(): raise RuntimeError("Learned rig transfer failed; inspect rigging/transfer.log") receipt.update( status="complete", finished=time.time(), rig_sha256=digest(rigged), source_fbx_sha256=digest(source_fbx), transfer=json.loads(rigged.with_suffix(".transfer.json").read_text()), ) return RigResult(rigged, source_fbx, receipt) except Exception as exc: receipt.update(status="failed", error=str(exc), finished=time.time()) raise finally: receipt_path.write_text(json.dumps(receipt, indent=2)) def _rig_skintokens(mesh: Path, output_dir: Path, notify=None) -> RigResult: import httpx from gradio_client import Client, handle_file from huggingface_hub import HfApi, get_token output_dir.mkdir(parents=True, exist_ok=True) space = os.environ.get("STUDIO_RIG_SPACE", "mantrakp/component-studio-rig-gpu") receipt = { "provider": space, "endpoint": "/rig", "source_sha256": digest(mesh), "started": time.time(), "status": "running", } receipt_path = output_dir / "rigging.json" receipt_path.write_text(json.dumps(receipt, indent=2)) try: token = os.environ.get("HF_TOKEN") or get_token() receipt["provider_revision"] = HfApi(token=token).space_info(space).sha client = Client( space, token=token, download_files=output_dir / "provider", verbose=False, httpx_kwargs={"timeout": httpx.Timeout(180, connect=30)}, ) job = client.submit(handle_file(str(mesh.resolve())), api_name="/rig") previous = None while not job.done(): status = job.status().code.name if status != previous and notify: notify(f"Learned character rig: {status.lower()}") previous = status time.sleep(2) result = job.result() if not isinstance(result, (tuple, list)) or len(result) != 2: raise RuntimeError("SkinTokens returned an invalid rig result") value, proof = result receipt["worker"] = proof if isinstance(value, dict): value = value.get("path") if not isinstance(value, str) or not Path(value).is_file() or Path(value).suffix.lower() != ".glb": raise RuntimeError("SkinTokens did not return a GLB file") if not isinstance(proof, dict) or proof.get("status") != "complete": raise RuntimeError("SkinTokens did not complete preservation verification") preservation = proof.get("preservation", {}) if not isinstance(preservation, dict): raise RuntimeError("SkinTokens rig failed preservation receipt validation") error = preservation.get("max_position_uv_error") skins = preservation.get("skins") joints = preservation.get("joints") vertices = preservation.get("validated_skin_vertices") if ( proof.get("source_sha256") != receipt["source_sha256"] or proof.get("output_sha256") != digest(Path(value)) or preservation.get("decoded_textures_preserved") is not True or preservation.get("oriented_triangles_preserved") is not True or type(error) not in (int, float) or not 0 <= error <= 1e-5 or type(vertices) is not int or vertices <= 0 or type(skins) is not int or skins <= 0 or not isinstance(joints, list) or len(joints) != skins or any(type(count) is not int or count <= 0 for count in joints) ): raise RuntimeError("SkinTokens rig failed preservation receipt validation") rigged = output_dir / "rigged.glb" shutil.copy2(value, rigged) receipt.update(status="complete", finished=time.time(), rig_sha256=digest(rigged)) return RigResult(rigged, None, receipt) except Exception as exc: receipt.update(status="failed", error=str(exc), finished=time.time()) raise finally: receipt_path.write_text(json.dumps(receipt, indent=2)) def recover_normalization(source: Path, normalized: Path) -> dict: """Recover the provider normalization using preserved indexed mesh topology.""" import numpy as np import trimesh original = trimesh.load(source, force="mesh") canonical = trimesh.load(normalized, force="mesh") if original.vertices.shape != canonical.vertices.shape or not np.array_equal( original.faces, canonical.faces ): raise RuntimeError("Rig normalization changed topology; cannot safely restore original coordinates") homogeneous = np.column_stack((original.vertices, np.ones(len(original.vertices)))) transform = np.linalg.lstsq(homogeneous, canonical.vertices, rcond=None)[0] residual = float(np.max(np.linalg.norm(homogeneous @ transform - canonical.vertices, axis=1))) if residual > float(np.max(canonical.extents)) * 1e-5: raise RuntimeError("Rig normalization is not an affine transform of the production mesh") matrix = np.eye(4) matrix[:3] = transform.T conversion = np.array([[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]]) return { "inverse_blender": (conversion @ np.linalg.inv(matrix) @ np.linalg.inv(conversion)).tolist(), "normalized_height": float(np.ptp(canonical.vertices[:, 1])), "fit_residual": residual, }