"""Blender-hosted executor for managed Scene Recipe jobs.""" from __future__ import annotations import hashlib import json import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any import bpy sys.path.insert(0, str(Path(__file__).resolve().parent)) from scene_recipe import SceneBuilder ADAPTER_VERSION = "1.0.0" PREVIEW_FILENAMES = { f"preview_{index:02d}": f"preview-{index:02d}.png" for index in range(1, 9) } 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 sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def terminal( record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], error: dict[str, str], ) -> dict[str, Any]: return { "job_id": record.get("job_id", ""), "lease_id": record.get("lease_id", ""), "request_digest": record.get("request_digest", ""), "status": status, "error": error, "artifact_manifest": artifacts, "terminal_at": datetime.now(timezone.utc).isoformat(), } def artifact(path: Path, artifact_id: str, media_type: str) -> dict[str, Any]: if not path.is_file() or path.stat().st_size == 0: raise RuntimeError(f"OUTPUT_EXPORT_EMPTY:{path.name}") if path.suffix.lower() == ".png" and path.read_bytes()[:8] != b"\x89PNG\r\n\x1a\n": raise RuntimeError("PNG_EXPORT_INVALID") return { "artifact_id": artifact_id, "filename": path.name, "media_type": media_type, "size_bytes": path.stat().st_size, "sha256": sha256(path), } def validate_semantics(request: dict[str, Any], spec: dict[str, Any]) -> list[dict[str, Any]]: if request.get("inputs") or spec.get("type") != "recipe" or spec.get("recipe_version") != 1: raise ValueError("BLENDER_REQUEST_SEMANTICS_INVALID") views = list(spec["render"]["views"]) output_keys = [item["output_key"] for item in views] if len(output_keys) != len(set(output_keys)) or "preview_01" not in output_keys: raise ValueError("BLENDER_RENDER_VIEWS_INVALID") requested = { item.get("key") for item in request["outputs"] if isinstance(item, dict) and isinstance(item.get("key"), str) } expected = set(output_keys) - {"preview_01"} if requested - {"scene_glb"} != expected: raise ValueError("BLENDER_RENDER_OUTPUTS_MISMATCH") return views def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]: request = record["request"] spec = request["operation"]["scene"] views = validate_semantics(request, spec) builder = SceneBuilder(spec) builder.compile() output = job_dir / "output" output.mkdir(exist_ok=True) project = output / "scene.blend" manifest = output / "scene-manifest.json" provenance = output / "provenance.json" bpy.ops.wm.save_as_mainfile(filepath=str(project), check_existing=False) rendered: list[tuple[Path, str]] = [] for view in views: builder.configure_camera(view) output_id = view["output_key"] preview = output / PREVIEW_FILENAMES[output_id] bpy.context.scene.render.filepath = str(preview) bpy.ops.render.render(write_still=True) rendered.append((preview, output_id)) atomic_json(manifest, builder.manifest(views)) workspace = json.loads((job_dir / "workspace.json").read_text(encoding="utf-8")) atomic_json( provenance, { "adapter_version": ADAPTER_VERSION, "blender_version": bpy.app.version_string, "request_digest": record["request_digest"], "workspace_mode": workspace.get("mode"), "generator": "scene_recipe@1", }, ) artifacts = [ artifact(project, "project", "application/x-blender"), *[artifact(path, output_id, "image/png") for path, output_id in rendered], artifact(manifest, "scene_manifest", "application/json"), ] if any(item.get("key") == "scene_glb" for item in request["outputs"]): glb = output / "scene.glb" bpy.ops.export_scene.gltf( filepath=str(glb), export_format="GLB", export_apply=True, use_renderable=True, ) artifacts.append(artifact(glb, "scene_glb", "model/gltf-binary")) artifacts.append(artifact(provenance, "provenance", "application/json")) return artifacts def job_directory() -> Path: arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(arguments) != 1: raise ValueError("BLENDER_JOB_DIRECTORY_MISSING") return Path(arguments[0]).resolve(strict=True) def main() -> int: job_dir = job_directory() record: dict[str, Any] = {} try: record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8")) artifacts = execute(job_dir, record) atomic_json(job_dir / "artifacts.json", artifacts) atomic_json(job_dir / "terminal.json", terminal(record, "succeeded", artifacts, {})) print("[OK] Blender scene recipe completed") return 0 except Exception as exc: code = str(exc).split(":", 1)[0] if str(exc) else type(exc).__name__ atomic_json( job_dir / "terminal.json", terminal(record, "failed", [], {"code": code[:80], "detail": str(exc)[:500]}), ) print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())