177 lines
6.0 KiB
Python
177 lines
6.0 KiB
Python
"""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())
|