184 lines
5.5 KiB
Python
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())
|