zcbot/windows-node/adapters/blender.scene.author@v2/worker.py

184 lines
5.5 KiB
Python

"""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.2.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-exit-code",
"1",
"--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 <job-directory>", file=sys.stderr)
return 2
return run(Path(sys.argv[1]))
if __name__ == "__main__":
raise SystemExit(main())