"""Fixed launcher for the Blender scene-authoring adapter.""" from __future__ import annotations import json import os import re import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any ADAPTER_VERSION = "0.1.0" BLENDER_ENV = "ZCBOT_BLENDER_EXE" def _atomic_json(path: Path, value: Any) -> None: temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) try: with temporary.open("w", encoding="utf-8", newline="\n") as handle: json.dump(value, handle, ensure_ascii=False, indent=2) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def _candidate_paths() -> list[Path]: candidates: list[Path] = [] configured = os.environ.get(BLENDER_ENV) if configured: candidates.append(Path(configured)) discovered = shutil.which("blender.exe") or shutil.which("blender") if discovered: candidates.append(Path(discovered)) for variable in ("ProgramFiles", "ProgramW6432"): root = os.environ.get(variable) if root: candidates.extend( sorted( Path(root).glob("Blender Foundation/Blender */blender.exe"), reverse=True, ) ) result: list[Path] = [] seen: set[str] = set() for candidate in candidates: try: resolved = candidate.expanduser().resolve(strict=True) except (OSError, RuntimeError): continue key = str(resolved).casefold() if key not in seen and resolved.is_file(): seen.add(key) result.append(resolved) return result def _version(executable: Path) -> str: result = subprocess.run( [str(executable), "--version"], check=False, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=7, ) output = result.stdout + "\n" + result.stderr match = re.search(r"Blender\s+([0-9]+(?:\.[0-9]+){1,2})", output) if result.returncode != 0 or match is None: raise RuntimeError("Blender version probe failed") return match.group(1) def _find_blender() -> tuple[Path, str]: failures: list[str] = [] for candidate in _candidate_paths(): try: return candidate, _version(candidate) except (OSError, subprocess.SubprocessError, RuntimeError) as exc: failures.append(f"{candidate.name}: {exc}") detail = "; ".join(failures) if failures else "Blender installation was not found" raise FileNotFoundError(detail) def _record(job_dir: Path) -> dict[str, Any]: try: value = json.loads( (job_dir / "request" / "request.json").read_text(encoding="utf-8") ) return value if isinstance(value, dict) else {} except (OSError, json.JSONDecodeError): return {} def _failure(job_dir: Path, code: str, detail: str) -> None: record = _record(job_dir) _atomic_json( job_dir / "terminal.json", { "job_id": record.get("job_id", ""), "lease_id": record.get("lease_id", ""), "request_digest": record.get("request_digest", ""), "status": "failed", "error": {"code": code, "detail": detail[:500]}, "artifact_manifest": [], "terminal_at": datetime.now(timezone.utc).isoformat(), }, ) def probe() -> int: try: executable, software_version = _find_blender() health = "ready" detail = f"Blender background executable detected: {executable}" except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc: software_version = None health = "unavailable" detail = str(exc) print( json.dumps( { "adapter_version": ADAPTER_VERSION, "software": "Blender", "software_version": software_version, "health": health, "detail": detail, }, ensure_ascii=False, ) ) return 0 def run(job_dir: Path) -> int: job_dir = job_dir.resolve(strict=True) script = Path(__file__).with_name("blender_worker.py").resolve(strict=True) try: executable, _ = _find_blender() result = subprocess.run( [ str(executable), "--background", "--factory-startup", "--disable-autoexec", "--python", str(script), "--", str(job_dir), ], check=False, ) if not (job_dir / "terminal.json").is_file(): _failure( job_dir, "BLENDER_NO_TERMINAL", f"Blender exited with code {result.returncode} without terminal.json", ) return result.returncode except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc: _failure(job_dir, "BLENDER_START_FAILED", str(exc)) print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr) return 1 def main() -> int: if sys.argv[1:] == ["--probe"]: return probe() if len(sys.argv) != 2: print("[ERR] Usage: worker.py ", file=sys.stderr) return 2 return run(Path(sys.argv[1])) if __name__ == "__main__": raise SystemExit(main())