| from dataclasses import dataclass |
| from torch import Tensor |
| from typing import Dict, Optional, List, Tuple |
|
|
| import atexit |
| import io |
| import os |
| import signal |
| import subprocess |
| import sys |
| import time |
| import torch |
| import uuid |
|
|
| from ..rig_package.info.asset import Asset |
| from ..model.tokenrig import TokenRig |
|
|
| |
| |
| |
| BPY_IPC_DIR = os.path.join(os.environ.get("TMPDIR", "/tmp"), "bpy_ipc") |
| BPY_IPC_POLL_INTERVAL = 0.05 |
| BPY_IPC_TIMEOUT = 180 |
| BPY_IPC_STARTUP_TIMEOUT = 30 |
|
|
| @dataclass |
| class TensorPacket: |
| """make sure stays on cpu""" |
| validate: bool=False |
| know_skeleton: bool=False |
| learned_mesh_cond: Optional[Tensor]=None |
| cond_latents: Optional[Tensor]=None |
| mesh_cond: Optional[Tensor]=None |
| vertices: Optional[Tensor]=None |
| assets: Optional[List[Asset]]=None |
| output_ids: Optional[Tensor]=None |
| start_embed_list: Optional[List[Tensor]]=None |
| start_tokens_list: Optional[List[List[int]]]=None |
|
|
| def to_device(self, device): |
| if self.learned_mesh_cond is not None: |
| self.learned_mesh_cond = self.learned_mesh_cond.to(device) |
| if self.cond_latents is not None: |
| self.cond_latents = self.cond_latents.to(device) |
| if self.mesh_cond is not None: |
| self.mesh_cond = self.mesh_cond.to(device) |
| if self.vertices is not None: |
| self.vertices = self.vertices.to(device) |
| if self.output_ids is not None: |
| self.output_ids = self.output_ids.to(device) |
| if self.start_embed_list is not None: |
| self.start_embed_list = [x.to(device) for x in self.start_embed_list] |
|
|
| @property |
| def B(self): |
| assert self.learned_mesh_cond is not None |
| return self.learned_mesh_cond.shape[0] |
|
|
| def to_bytes(self): |
| return object_to_bytes(self) |
|
|
| @classmethod |
| def from_bytes(cls, bytes) -> 'TensorPacket': |
| return bytes_to_object(bytes) |
|
|
|
|
| def object_to_bytes(t): |
| buffer = io.BytesIO() |
| torch.save(t, buffer) |
| return buffer.getvalue() |
|
|
| def bytes_to_object(b, map_location=None): |
| return torch.load(io.BytesIO(b), weights_only=False, map_location=map_location) |
|
|
| def get_model( |
| ckpt_path: str, |
| hf_path: Optional[str]=None, |
| device='cuda', |
| ) -> TokenRig: |
| model = TokenRig.load_from_system_checkpoint(checkpoint_path=ckpt_path) |
| if hf_path is not None: |
| from transformers import AutoModel |
| a = AutoModel.from_pretrained( |
| hf_path, |
| local_files_only=True, |
| _attn_implementation="flash_attention_2", |
| torch_dtype=torch.bfloat16, |
| ) |
| model.transformer.model.load_state_dict(a.state_dict()) |
|
|
| model = model.to(device) |
| return model |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def bpy_server_request(op: str, data, proc: "Optional[subprocess.Popen]" = None, |
| timeout: "Optional[float]" = None): |
| """Send *op* with *data* to the bpy worker via the filesystem IPC dir. |
| |
| This is a **stateless** function that only does file I/O — it can be |
| called from the main process or any ZeroGPU worker process. |
| |
| Parameters |
| ---------- |
| op : str |
| One of ``"ping"``, ``"load"``, ``"export"``, ``"transfer"``. |
| data : object |
| Arbitrary pickle-able / torch-save-able payload. |
| proc : subprocess.Popen or None |
| If a handle is provided the function will abort early when the |
| worker process dies unexpectedly. |
| timeout : float or None |
| Maximum seconds to wait. Defaults to ``BPY_IPC_TIMEOUT``. |
| |
| Returns |
| ------- |
| object |
| Deserialised result. |
| |
| Raises |
| ------ |
| RuntimeError |
| On timeout, worker crash, or worker-reported error. |
| """ |
| if timeout is None: |
| timeout = BPY_IPC_TIMEOUT |
|
|
| |
| os.makedirs(BPY_IPC_DIR, exist_ok=True) |
|
|
| req_id = f"req_{uuid.uuid4().hex}" |
| req_torch = os.path.join(BPY_IPC_DIR, f"{req_id}.torch") |
| req_ready = os.path.join(BPY_IPC_DIR, f"{req_id}.ready") |
| resp_torch = os.path.join(BPY_IPC_DIR, f"resp_{req_id[4:]}.torch") |
| resp_done = os.path.join(BPY_IPC_DIR, f"resp_{req_id[4:]}.done") |
|
|
| |
| with open(req_torch, "wb") as f: |
| f.write(object_to_bytes((op, data))) |
|
|
| |
| with open(req_ready, "w") as f: |
| f.write("") |
|
|
| |
| t0 = time.time() |
| _pid_check_interval = 2.0 |
| _last_pid_check = 0.0 |
| while not os.path.exists(resp_done): |
| elapsed = time.time() - t0 |
| if elapsed > timeout: |
| _cleanup_files(req_torch, req_ready) |
| raise RuntimeError( |
| f"bpy_server_request timeout after {timeout:.0f}s " |
| f"waiting for op='{op}' (id={req_id})" |
| ) |
| if proc is not None and proc.poll() is not None: |
| _cleanup_files(req_torch, req_ready) |
| raise RuntimeError( |
| f"bpy worker process exited unexpectedly (code={proc.returncode})" |
| ) |
| |
| |
| if elapsed - _last_pid_check > _pid_check_interval: |
| _last_pid_check = elapsed |
| if _bpy_server_pid() is None: |
| _cleanup_files(req_torch, req_ready) |
| raise RuntimeError( |
| f"bpy worker process died while waiting for op='{op}' (id={req_id})" |
| ) |
| time.sleep(BPY_IPC_POLL_INTERVAL) |
|
|
| |
| try: |
| with open(resp_torch, "rb") as f: |
| result = bytes_to_object(f.read()) |
| except Exception as exc: |
| _cleanup_files(req_torch, req_ready, resp_torch, resp_done) |
| raise RuntimeError(f"Failed to read response: {exc}") |
|
|
| |
| _cleanup_files(req_torch, req_ready, resp_torch, resp_done) |
|
|
| |
| if isinstance(result, str): |
| if result.startswith("error:"): |
| raise RuntimeError(f"bpy worker error: {result}") |
| if result.startswith("unsupported op"): |
| raise RuntimeError(f"bpy worker: {result}") |
|
|
| return result |
|
|
|
|
| def bpy_server_ping(timeout: float = 2.0) -> bool: |
| """Return ``True`` if the bpy worker is alive and responsive. |
| |
| Uses a short *timeout* (default 2 s) so callers are not blocked when |
| no bpy worker is running yet. |
| """ |
| try: |
| return bpy_server_request("ping", None, timeout=timeout) == "pong" |
| except Exception: |
| return False |
|
|
|
|
| def _bpy_server_pid() -> "Optional[int]": |
| """Return the PID of a running bpy worker, or ``None``. |
| |
| Reads ``{BPY_IPC_DIR}/.pid`` and verifies the process still exists. |
| This is a fast check (no I/O wait). |
| """ |
| pid_file = os.path.join(BPY_IPC_DIR, ".pid") |
| try: |
| with open(pid_file, "r") as f: |
| pid = int(f.read().strip()) |
| os.kill(pid, 0) |
| return pid |
| except (FileNotFoundError, ValueError, OSError): |
| return None |
|
|
|
|
| def is_bpy_server_alive() -> bool: |
| """Fast check: is a bpy worker process running? |
| |
| First checks the PID file (instant). If the PID is alive, optionally |
| verifies responsiveness with a short ping. |
| """ |
| return _bpy_server_pid() is not None |
|
|
|
|
| def _cleanup_files(*paths: str): |
| """Remove each file if it exists; never raise.""" |
| for p in paths: |
| try: |
| os.remove(p) |
| except OSError: |
| pass |
|
|
|
|
| def _cleanup_ipc_dir(): |
| """Remove all files from the IPC directory (not the dir itself).""" |
| if not os.path.isdir(BPY_IPC_DIR): |
| return |
| for fn in os.listdir(BPY_IPC_DIR): |
| try: |
| os.remove(os.path.join(BPY_IPC_DIR, fn)) |
| except OSError: |
| pass |
|
|
|
|
| def _kill_stale_bpy_worker(): |
| """If a bpy worker PID file exists and the process is alive, kill it. |
| |
| This prevents orphaned/hung workers from interfering with a new one. |
| """ |
| pid = _bpy_server_pid() |
| if pid is None: |
| return |
| print(f"[BpyClient] Killing stale bpy worker (pid={pid})", flush=True) |
| try: |
| os.kill(pid, signal.SIGTERM) |
| |
| for _ in range(50): |
| try: |
| os.kill(pid, 0) |
| except OSError: |
| break |
| time.sleep(0.1) |
| else: |
| |
| os.kill(pid, signal.SIGKILL) |
| except OSError: |
| pass |
|
|
|
|
| class BpyClient: |
| """Manages the bpy subprocess lifecycle and provides a ``request()`` API. |
| |
| Typical usage (singleton in the main process):: |
| |
| client = BpyClient.launch() |
| asset = client.request("load", "/path/to/model.fbx") |
| client.request("export", {"asset": asset, "filepath": "/out.glb"}) |
| |
| Worker processes that don't own the subprocess should use the stateless |
| ``bpy_server_request()`` function directly instead. |
| """ |
|
|
| def __init__(self, proc: subprocess.Popen): |
| self._proc = proc |
|
|
| |
|
|
| def request(self, op: str, data): |
| """Thin wrapper around :func:`bpy_server_request` that also monitors |
| the subprocess handle for unexpected exits.""" |
| return bpy_server_request(op, data, proc=self._proc) |
|
|
| def ping(self) -> bool: |
| return bpy_server_ping() |
|
|
| def is_alive(self) -> bool: |
| """Check whether the subprocess is still running.""" |
| return self._proc is not None and self._proc.poll() is None |
|
|
| def shutdown(self): |
| """Terminate the bpy worker process and clean up the IPC directory.""" |
| if self._proc is None: |
| return |
| print(f"[BpyClient] Terminating bpy worker (pid={self._proc.pid})", flush=True) |
| try: |
| os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM) |
| except ProcessLookupError: |
| pass |
| try: |
| self._proc.wait(timeout=10) |
| except subprocess.TimeoutExpired: |
| os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL) |
| self._proc.wait() |
| self._proc = None |
| |
| _cleanup_ipc_dir() |
|
|
| |
|
|
| @classmethod |
| def launch(cls) -> "BpyClient": |
| """Start the bpy worker subprocess and wait until it is ready. |
| |
| Returns a ``BpyClient`` instance ready to accept requests. |
| """ |
| |
| _kill_stale_bpy_worker() |
|
|
| |
| _cleanup_ipc_dir() |
| os.makedirs(BPY_IPC_DIR, exist_ok=True) |
|
|
| |
| here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| proc = subprocess.Popen( |
| [sys.executable, os.path.join(here, "bpy_server.py")], |
| stdout=None, |
| stderr=None, |
| preexec_fn=os.setsid, |
| ) |
| print(f"[BpyClient] bpy worker started (pid={proc.pid})", flush=True) |
|
|
| client = cls(proc) |
|
|
| |
| atexit.register(client.shutdown) |
|
|
| |
| ready_flag = os.path.join(BPY_IPC_DIR, ".ready") |
| t0 = time.time() |
| last_log = 0.0 |
| while not os.path.exists(ready_flag): |
| if time.time() - t0 > BPY_IPC_STARTUP_TIMEOUT: |
| client.shutdown() |
| raise RuntimeError( |
| f"bpy worker failed to start within {BPY_IPC_STARTUP_TIMEOUT}s" |
| ) |
| if proc.poll() is not None: |
| raise RuntimeError( |
| f"bpy worker exited during startup (code={proc.returncode})" |
| ) |
| now = time.time() |
| if now - last_log > 10: |
| print(f"[BpyClient] still waiting for bpy worker ({now - t0:.0f}s elapsed)", flush=True) |
| last_log = now |
| time.sleep(0.5) |
|
|
| print(f"[BpyClient] bpy worker is ready (after {time.time() - t0:.1f}s)", flush=True) |
| return client |
|
|